《Java 网络编程基础》8.3 路由、静态文件与错误响应

路由决定一个 HttpRequest 最终由谁处理。本节实现 HttpRouter 与 HttpRouterImpl,注册 /healthz 与 /books/{id},区分 404、405 与 500,并在静态文件服务里做百分号解码、路径规范化与根目录前缀校验,挡住 ../、%2e%2e、符号链接与编码斜杠。

本节目标:把 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 按以下顺序处理:

  1. 只允许 GET/HEAD。 其他方法先走路由;没有匹配就 405,不落到静态分支。
  2. 百分号解码一次。 自己实现 decodePath,遇到 %00、%2f、%5c 直接 400。不能用 URLDecoder,它会把 + 解成空格,路径里的 + 是合法字符。
  3. 拒绝 NUL、反斜杠、控制字符和裸非 ASCII。 请求目标应该是 ASCII;UTF-8 文件名用百分号编码。
  4. 规范化后做前缀校验。 root.resolve(relative).normalize() 之后必须 startsWith(root),否则 403。../ 和 %2e%2e 都在这一步被挡住。
  5. 拒绝符号链接。 Files.isRegularFile(candidate, NOFOLLOW_LINKS) 对符号链接返回 false;再用 candidate.toRealPath() 校验真实路径仍在根目录内,缩小检查与读取之间被符号链接替换的窗口。
  6. 只服务普通文件。 目录返回 404,不做目录列表,也不自动找 index.html;需要首页就显式注册路由。
  7. 限制大小与类型。 超过 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 帧类型与流状态 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发