本节目标:把
HttpRequest映射到处理函数,正确区分 404、405、500,并让静态文件服务无法读出根目录之外的文件。
适用版本:JDK 25(Temurin 25.0.5+8);Path.normalize、toRealPath、Files.isRegularFile、CopyOnWriteArrayList。
承接的问题
8.2 的 KeepAliveHandler 解析出 HttpRequest 后,把请求交给 router.route(request)。第一版路由往往只用一个 Map<String, HandlerFn>,键是路径,值是函数。它很快暴露两个问题:POST /healthz 返回 404,客户端无法区分「路径不存在」和「方法不允许」;静态文件用 root.resolve(path.substring(1)) 直接拼路径,GET /../secret.txt 就能读到根目录外的文件。
linkd 的 HTTP 层按 BRIEF 的契约提供 HttpRouter 接口:add(method, path, fn) 注册,route(req) 返回响应。实现类 HttpRouterImpl 负责方法匹配、405 的 Allow、异常兜底 500,以及静态文件的根目录约束。本节把这三件事一次做完。
核心机制一:路由接口与处理函数
路由层不碰 SocketChannel,也不负责编码响应;它只做「请求进、响应出」的映射。两个类型都很小:
package com.plume.linkd.http;
@FunctionalInterface
public interface HandlerFn {
HttpResponse handle(HttpRequest request);
}
package com.plume.linkd.http;
public interface HttpRouter {
void add(String method, String path, HandlerFn fn);
HttpResponse route(HttpRequest req);
}
HandlerFn 只接收 HttpRequest,不接收路径参数。/books/{id} 这种模式仍然可以注册,处理器从 req.path() 里截取 id。这个取舍是为了让签名保持最小:真要注入路径参数,就得把参数表塞进 HttpRequest,或者把 HandlerFn 改成接收第二个 Map,两种都会扩散到整章。代价是处理器自己解析路径,REST 资源多了以后容易重复。
add 在启动阶段调用,route 在事件循环线程里调用。实现用 CopyOnWriteArrayList 保存路由,读多写少时不需要额外加锁;如果运行期还要动态注册路由,应该换成不可变快照加原子替换,避免遍历时看到半成品。
核心机制二:匹配、405 与 500
路径按 / 切成段,逐段比较。普通段必须完全相等,{name} 形式的段匹配任意一个非空段。/books/42 命中 /books/{id},/books/ 和 /books/42/extra 都不命中。这里不做尾斜杠重定向:/healthz/ 是另一个路径,返回 404,避免客户端在重定向里丢掉方法或 body。
方法匹配分三种结果:
- 路径和方法都命中:调用处理器,返回响应。
- 路径命中、方法不命中:收集该路径允许的方法,返回 405,并在
Allow头里列出。GET 存在时补上 HEAD,因为 HEAD 按 GET 处理、只去掉 body。 - 路径不命中:尝试静态文件;仍不命中返回 404。
这条路让 /healthz 的 POST 得到 405 而不是 404。反过来,/books/abc 命中了 /books/{id} 模式,处理器发现 id 不是数字,返回 400;它不应该变成 404,因为路由本身是存在的,只是参数不合法。
处理器抛 RuntimeException 时,路由捕获、记录完整堆栈,返回固定的 500 文本。不能把异常消息拼进响应体:它可能包含文件路径、SQL、内部主机名。日志里保留 req.method() 和 req.target(),响应体只写 internal server error。
HEAD 的处理在路由层完成:按 GET 找到处理器,拿到完整响应后把 body 置空,同时写入原 body 长度作为 Content-Length。这样 HEAD /healthz 返回 Content-Length: 3 但没有 body,客户端可以用它判断资源大小。
核心机制三:静态文件的根目录约束
静态文件的危险不在读文件,而在「用户输入的路径」和「文件系统路径」之间的转换。HttpRouterImpl 按以下顺序处理:
- 只允许 GET/HEAD。 其他方法先走路由;没有匹配就 405,不落到静态分支。
- 百分号解码一次。 自己实现
decodePath,遇到%00、%2f、%5c直接 400。不能用URLDecoder,它会把+解成空格,路径里的+是合法字符。 - 拒绝 NUL、反斜杠、控制字符和裸非 ASCII。 请求目标应该是 ASCII;UTF-8 文件名用百分号编码。
- 规范化后做前缀校验。
root.resolve(relative).normalize()之后必须startsWith(root),否则 403。../和%2e%2e都在这一步被挡住。 - 拒绝符号链接。
Files.isRegularFile(candidate, NOFOLLOW_LINKS)对符号链接返回 false;再用candidate.toRealPath()校验真实路径仍在根目录内,缩小检查与读取之间被符号链接替换的窗口。 - 只服务普通文件。 目录返回 404,不做目录列表,也不自动找
index.html;需要首页就显式注册路由。 - 限制大小与类型。 超过 8 MiB 返回 500,
Content-Type按扩展名白名单给出,并加X-Content-Type-Options: nosniff。
这些检查的顺序有讲究。先解码再规范化,才能让 %2e%2e 变成 .. 被 normalize 处理;先规范化再解码,编码过的 .. 会绕过前缀校验。编码斜杠必须在解码阶段拒绝,否则 %2f 会改变段结构,让「一个段」变成「两个段」。
在 linkd 里落地:HttpRouterImpl
下面的实现把 /healthz 和 /books/{id} 注册在构造器里。/healthz 返回 200 和 ok\n;/books/{id} 返回固定假响应,标注为示意数据,没有真的访问 libraryd 的 8080 端口:
package com.plume.linkd.http;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
public final class HttpRouterImpl implements HttpRouter {
private record Route(String method, String[] segments, HandlerFn fn) { }
private static final System.Logger LOG = System.getLogger("linkd.http.router");
private static final long MAX_STATIC_BYTES = 8L * 1024 * 1024;
private final List<Route> routes = new CopyOnWriteArrayList<>();
private final Path staticRoot;
public HttpRouterImpl(Path staticRoot) {
this.staticRoot = staticRoot == null ? null : staticRoot.toAbsolutePath().normalize();
add("GET", "/healthz", req -> HttpResponse.text(200, "ok\n"));
add("GET", "/books/{id}", req -> {
String id = req.path().substring("/books/".length());
if (!id.matches("[0-9]{1,9}")) return error(400, "invalid book id", Map.of());
return HttpResponse.json(200, "{\"id\":" + id + ",\"title\":\"示意图书\",\"available\":true}");
});
}
@Override
public void add(String method, String path, HandlerFn fn) {
Objects.requireNonNull(fn, "fn");
if (!path.startsWith("/")) throw new IllegalArgumentException("path must start with /");
routes.add(new Route(method.toUpperCase(Locale.ROOT), split(path), fn));
}
@Override
public HttpResponse route(HttpRequest req) {
String path = req.path();
boolean head = req.isHead();
String method = head ? "GET" : req.method();
List<String> allowed = new ArrayList<>();
for (Route route : routes) {
if (!matches(route.segments(), path)) continue;
if (route.method().equals(method)) {
try {
HttpResponse response = route.fn().handle(req);
return head ? withoutBody(response) : response;
} catch (RuntimeException e) {
LOG.log(System.Logger.Level.ERROR, "route failed: " + req.method() + " " + req.target(), e);
return error(500, "internal server error", Map.of());
}
}
allowed.add(route.method());
}
if (!allowed.isEmpty()) {
if (allowed.contains("GET") && !allowed.contains("HEAD")) allowed.add("HEAD");
return error(405, "method not allowed", Map.of("Allow", String.join(", ", allowed)));
}
if (staticRoot != null && (method.equals("GET") || head)) {
HttpResponse response = serveStatic(path);
if (response != null) return head ? withoutBody(response) : response;
}
return error(404, "not found", Map.of());
}
private HttpResponse serveStatic(String rawPath) {
String decoded;
try {
decoded = decodePath(rawPath);
} catch (IllegalArgumentException e) {
return error(400, "invalid path", Map.of());
}
if (decoded.indexOf('\0') >= 0 || decoded.indexOf('\\') >= 0) return error(400, "invalid path", Map.of());
String relative = decoded.startsWith("/") ? decoded.substring(1) : decoded;
Path candidate = staticRoot.resolve(relative).normalize();
if (!candidate.startsWith(staticRoot)) return error(403, "forbidden", Map.of());
if (!Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)) return null;
try {
Path real = candidate.toRealPath();
if (!real.startsWith(staticRoot.toRealPath())) return error(403, "forbidden", Map.of());
if (Files.size(real) > MAX_STATIC_BYTES) return error(500, "static file too large", Map.of());
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Content-Type", contentType(real.getFileName().toString()));
headers.put("X-Content-Type-Options", "nosniff");
return new HttpResponse(200, headers, Files.readAllBytes(real));
} catch (IOException e) {
LOG.log(System.Logger.Level.ERROR, "static file failed: " + candidate, e);
return error(500, "internal server error", Map.of());
}
}
private static String decodePath(String raw) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '%') {
if (i + 2 >= raw.length()) throw new IllegalArgumentException("truncated escape");
int hi = Character.digit(raw.charAt(i + 1), 16);
int lo = Character.digit(raw.charAt(i + 2), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad escape");
int value = (hi << 4) | lo;
if (value == 0 || value == '/' || value == '\\') throw new IllegalArgumentException("encoded separator");
bytes.write(value);
i += 2;
} else if (c > 0x7f) {
throw new IllegalArgumentException("non-ascii path");
} else if (c < 0x20 || c == 0x7f) {
throw new IllegalArgumentException("control character");
} else {
bytes.write(c);
}
}
return bytes.toString(StandardCharsets.UTF_8);
}
private static HttpResponse withoutBody(HttpResponse response) {
Map<String, String> headers = new LinkedHashMap<>(response.headers());
headers.keySet().removeIf(key -> key.equalsIgnoreCase("Content-Length"));
headers.put("Content-Length", Integer.toString(response.body().length));
return new HttpResponse(response.status(), headers, new byte[0]);
}
private static HttpResponse error(int status, String message, Map<String, String> extra) {
Map<String, String> headers = new LinkedHashMap<>(extra);
headers.put("Content-Type", "text/plain; charset=utf-8");
headers.put("X-Content-Type-Options", "nosniff");
return new HttpResponse(status, headers, (message + "\n").getBytes(StandardCharsets.UTF_8));
}
private static boolean matches(String[] pattern, String path) {
String[] actual = split(path);
if (pattern.length != actual.length) return false;
for (int i = 0; i < pattern.length; i++) {
String segment = pattern[i];
if (segment.startsWith("{") && segment.endsWith("}")) {
if (actual[i].isEmpty()) return false;
continue;
}
if (!segment.equals(actual[i])) return false;
}
return true;
}
private static String[] split(String path) {
if (path.equals("/")) return new String[0];
return path.substring(1).split("/", -1);
}
private static String contentType(String fileName) {
int dot = fileName.lastIndexOf('.');
String ext = dot < 0 ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
return switch (ext) {
case "html", "htm" -> "text/html; charset=utf-8";
case "css" -> "text/css; charset=utf-8";
case "js" -> "text/javascript; charset=utf-8";
case "json" -> "application/json; charset=utf-8";
case "txt" -> "text/plain; charset=utf-8";
case "svg" -> "image/svg+xml";
case "png" -> "image/png";
case "jpg", "jpeg" -> "image/jpeg";
case "ico" -> "image/x-icon";
default -> "application/octet-stream";
};
}
}
route 的循环里,allowed 只收集「路径匹配但方法不匹配」的方法。如果路径根本不匹配,循环不会加入任何东西,自然落到静态文件或 404。405 的 Allow 用逗号加空格拼接,GET 存在时补 HEAD;没有排序,也没有去重,因为同一路径注册两次相同方法是配置错误,应该在上线前的自检里发现。
静态分支里的 staticRoot.toRealPath() 每个请求调用一次,会多一次系统调用。教学实现先保证正确;生产环境可以在构造器里把根目录的真实路径缓存下来,并在启动时校验根目录存在、可读、不是符号链接。
本机验证:状态码、Allow 与穿越拦截
测试在临时目录下创建 site/hello.txt、site/app.css、site/sub/,并在根目录外放一个 secret.txt,再在根目录里建一个指向它的符号链接。本机输出(Temurin 25.0.5+8;为便于阅读,省略了与结论无关的 type、allow、length 字段,状态码与 body 原样保留):
healthz: status=200 type=text/plain; charset=utf-8 body=ok\n
healthz-post: status=405 allow=GET, HEAD body=method not allowed\n
healthz-head: status=200 length=3 body=
missing: status=404 body=not found\n
static-txt: status=200 type=text/plain; charset=utf-8 body=hello static\n
static-css: status=200 type=text/css; charset=utf-8 body=body{}\n
static-dir: status=404 body=not found\n
traversal-raw: status=403 body=forbidden\n
traversal-encoded: status=403 body=forbidden\n
encoded-slash: status=400 body=invalid path\n
symlink: status=404 body=not found\n
books-42: status=200 type=application/json; charset=utf-8 body={"id":42,"title":"示意图书","available":true}
books-bad: status=400 body=invalid book id\n
boom: status=500 body=internal server error\n
逐行对照:healthz-post 的 405 带 Allow: GET, HEAD;healthz-head 的 Content-Length: 3 是 ok\n 的长度,body 为空;missing 是路径完全不存在;static-dir 说明目录不自动列目录;traversal-raw 的 /../secret.txt 规范化后跑出根目录,403;traversal-encoded 的 /%2e%2e/secret.txt 解码后再规范化,同样 403;encoded-slash 的 /%2fetc/passwd 在解码阶段就被拒绝,400;symlink 指向根目录外,NOFOLLOW_LINKS 让它不被当成普通文件,最终 404;books-42 是固定假响应;books-bad 命中了模式但 id 非法,400;boom 的处理器抛异常,路由记录堆栈并回 500。
这里没有出现 500 的静态文件分支,因为测试目录里没有超过 8 MiB 的文件,也没有读权限错误。500 的兜底路径由 boom 路由覆盖;静态分支的 catch (IOException) 走的是同一套响应构造,差别只在日志消息。
常见错误与边界
反例一:直接 resolve 用户路径。 下面这段看起来最短,实际把根目录外的文件也暴露了:
// 反例:没有 normalize,也没有前缀校验
Path file = root.resolve(req.path().substring(1));
return new HttpResponse(200, Map.of(), Files.readAllBytes(file));
GET /../secret.txt 会解析成 root/../secret.txt,也就是根目录的上一级。normalize() 只是把 .. 折叠掉,折叠之后还必须检查 startsWith(root),两步缺一不可。
错误二:路径不命中就直接 404。 先按路径找处理器、再看方法,会让 POST /healthz 得到 404。客户端无法判断是地址写错还是方法用错,缓存和监控也会把 405 统计成 404。正确顺序是先匹配路径、收集允许的方法,再决定 405 还是 404。
错误三:把异常消息回给客户端。 e.getMessage() 可能包含 /Users/.../secret.txt、SQL 片段、内部主机名。日志里记录完整异常,响应体只给固定文本。System.Logger 的 log(Level.ERROR, msg, e) 会把堆栈写进日志,不会写进响应。
错误四:用 URLDecoder 解路径。 它把 + 解成空格,把非法百分号静默替换;路径里的 + 是合法文件名字符,解错之后文件名对不上。自己实现解码器,才能对 %00、%2f、%5c 明确返回 400。
边界:检查与读取之间有 TOCTOU 窗口。 代码先 isRegularFile、再 toRealPath、再 readAllBytes;如果攻击者能在根目录里创建或替换符号链接,检查之后、读取之前仍可能换掉目标。彻底的做法是用 SecureDirectoryStream 打开目录并相对打开文件,或者把静态目录设为只读、由部署流程保证不可写。本节实现把风险降到「需要本地写权限」,没有声称完全消除。
边界:硬链接无法用路径前缀识别。 根目录里的硬链接和外部文件是同一个 inode,toRealPath 仍显示根目录内的路径。创建硬链接同样需要本地权限;如果静态目录可能被不可信用户写入,应该在文件系统层隔离,而不是只靠路径检查。
边界:大文件不适合 readAllBytes。 本节把静态文件读进 byte[],再用 HttpResponse 返回,8 MiB 上限是保护内存的硬闸。视频、安装包这类文件应该走 FileChannel.transferTo 或分块响应,13.3 会展开零拷贝的边界。
小结
HttpRouter只有add和route两个方法;HandlerFn只接收HttpRequest,路径参数由处理器从req.path()解析。- 匹配按
/分段,普通段全等,{name}匹配一个非空段;尾斜杠不做重定向。 - 路径命中但方法不命中返回 405,并给出
Allow;GET 存在时补 HEAD;路径完全不命中才 404。 - 处理器抛
RuntimeException返回 500,日志留堆栈,响应体只给固定文本。 - 静态文件先解码一次,再 normalize,再检查
startsWith(root);拒绝%00、%2f、%5c、NUL、反斜杠和控制字符。 NOFOLLOW_LINKS与toRealPath挡住符号链接;TOCTOU 和硬链接需要文件系统权限兜底,路径检查不是万能的。
到这里,HTTP/1.1 的解析、连接复用、chunked、路由和静态文件都跑通了。下一节进入 HTTP/2:从二进制帧头开始,看一条连接怎么承载多个流。
阅读导航:上一节:8.2 keep-alive、chunked 与管线化 · 下一节:9.1 HTTP/2 帧类型与流状态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。