milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s

This commit is contained in:
2026-08-07 17:55:59 +02:00
parent 9b12dbaaa0
commit c50c6d285a
57 changed files with 2926 additions and 126 deletions
+155 -2
View File
@@ -61,7 +61,7 @@ const log = std.log.scoped(.web_server);
const recv_buffer_len = 8 * 1024;
const send_buffer_len = 4 * 1024;
/// Ruling 7. 64 slots at ~15.7 KiB each is ~1 MiB of fixed connection state.
/// Ruling 7. 64 slots at ~16 KiB each is ~1 MiB of fixed connection state.
pub const default_max_connections: u16 = 64;
/// How much per-request arena a connection keeps between requests. Enough that
@@ -206,10 +206,13 @@ pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Reque
/// Ruling 19. No limiter wired means no limit: the limiter is a defence the
/// operator configures, and its absence must not refuse traffic.
///
/// Keyed on `client_addr`, not on the socket peer: behind a trusted proxy every
/// peer is the proxy, and one bucket for every remote user is no limiter at all.
pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
const limiter = state.limiter orelse return .ok;
const now = std.Io.Clock.awake.now(io);
return limiter.check(io, now, address.NetAddress.fromIp(request.peer));
return limiter.check(io, now, address.NetAddress.fromIp(request.client_addr));
}
/// Seam double: refuses nothing. For tests and for a server with no admin
@@ -281,6 +284,7 @@ pub const Server = struct {
cookie_buf: [http_util.max_cookie_len]u8,
accept_encoding_buf: [http_util.max_header_value_len]u8,
if_none_match_buf: [http_util.max_header_value_len]u8,
xff_buf: [http_util.max_xff_len]u8,
/// Per-request working memory, reset between requests on the same
/// connection so a keep-alive client cannot grow it without bound.
arena: std.heap.ArenaAllocator,
@@ -523,6 +527,19 @@ pub const Server = struct {
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
const peer = address.NetAddress.fromIp(conn.peer);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf);
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
.addr => |addr| addr,
.bad_forwarded_for => {
var view = bareRequest(request, conn, arena);
return http_util.respondError(&view, .bad_request, "malformed x-forwarded-for");
},
};
// A forwarded entry has no port of its own; the peer keeps the one it
// connected from.
const client_ip = if (client_addr.eql(peer)) conn.peer else client_addr.toIp(0);
// Decoding is destructive, so it runs on a copy: W8's asset lookup needs
// the raw path to match embedded file names byte for byte.
const decodable = arena.dupe(u8, raw_path) catch return error.OutOfMemory;
@@ -542,6 +559,7 @@ pub const Server = struct {
.accept_encoding = accept_encoding,
.if_none_match = if_none_match,
.peer = conn.peer,
.client_addr = client_ip,
.arena = arena,
};
return router.dispatch(self.state, io, &view);
@@ -560,6 +578,10 @@ pub const Server = struct {
.accept_encoding = "",
.if_none_match = "",
.peer = conn.peer,
// These responses are decided before the forwarded-for header is
// read, or because reading it failed; the socket peer is all that
// is known.
.client_addr = conn.peer,
.arena = arena,
};
}
@@ -629,6 +651,68 @@ fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []cons
return buf[0..value.len];
}
/// Copies the **last** `buf.len` bytes of one header value into `buf`. Null
/// means the header is absent, which is a different answer from an empty value.
///
/// The tail is what matters for `x-forwarded-for`, and `copyHeader`'s
/// empty-on-overflow rule would be a security hole here: an empty value reads as
/// "no header", the effective client falls back to the socket peer, and behind
/// the same-box proxy this feature exists for that peer is loopback — which
/// `web.api_localhost_exempt` exempts from the API limiter by default. A client
/// would regain the exemption by sending an oversized header. Keeping the tail
/// closes that: the proxy appends its entry last, so the entry that names the
/// real client is always in the final bytes.
fn copyHeaderSuffix(request: *http.Server.Request, name: []const u8, buf: []u8) ?[]const u8 {
const value = headerValue(request, name) orelse return null;
const tail = if (value.len > buf.len) value[value.len - buf.len ..] else value;
@memcpy(buf[0..tail.len], tail);
return buf[0..tail.len];
}
/// Who a request is from, or the one way deriving that can fail.
const ClientAddr = union(enum) {
addr: address.NetAddress,
bad_forwarded_for,
};
/// Milestone-17 ruling 4. The socket peer, unless it is a trusted proxy that
/// forwarded the request, in which case the last entry of `x-forwarded-for` —
/// the one that proxy appended, and the only entry in the chain a client cannot
/// write.
///
/// A missing header falls back to the peer: a trusted proxy that adds no header
/// is a proxy nobody asked to trust anything about, and the peer is still true.
/// A header that is present but whose last entry is not an IP literal fails
/// closed with a 400 instead. Only a misconfigured proxy can produce that — a
/// client's spoofed entries can never terminate the chain — and falling back
/// there would hand the request the loopback identity this ruling exists to
/// take away.
fn clientAddr(
trusted_proxies: []const u8,
peer: address.NetAddress,
forwarded_for: ?[]const u8,
) ClientAddr {
if (!trustsPeer(trusted_proxies, peer)) return .{ .addr = peer };
const chain = forwarded_for orelse return .{ .addr = peer };
const start = if (std.mem.lastIndexOfScalar(u8, chain, ',')) |comma| comma + 1 else 0;
const last = std.mem.trim(u8, chain[start..], " \t");
const addr = address.NetAddress.parse(last) catch return .bad_forwarded_for;
return .{ .addr = addr };
}
/// Whether `peer` is one of the configured trusted proxies. An element that is
/// not an IP literal matches nothing; `validate.zig` is what tells the operator
/// about it, and refusing to serve over a typo here would be a worse answer.
fn trustsPeer(trusted_proxies: []const u8, peer: address.NetAddress) bool {
var it = model.trustedProxies(trusted_proxies);
while (it.next()) |element| {
const trusted = address.NetAddress.parse(element) catch continue;
if (trusted.eql(peer)) return true;
}
return false;
}
/// The first value sent under `name`, borrowed from the request head.
fn headerValue(request: *http.Server.Request, name: []const u8) ?[]const u8 {
var it = request.iterateHeaders();
@@ -825,6 +909,75 @@ fn testRequest() http_util.Request {
.accept_encoding = "",
.if_none_match = "",
.peer = .{ .ip4 = .loopback(0) },
.client_addr = .{ .ip4 = .loopback(0) },
.arena = testing.allocator,
};
}
fn ip(text: []const u8) address.NetAddress {
return address.NetAddress.parse(text) catch unreachable;
}
test "with no trusted proxy configured the socket peer is the client" {
const peer = ip("192.0.2.1");
try testing.expect(clientAddr("", peer, "203.0.113.9").addr.eql(peer));
}
test "a spoofed forwarded-for from an untrusted peer is ignored" {
const peer = ip("192.0.2.1");
try testing.expect(clientAddr("10.0.0.1", peer, "203.0.113.9").addr.eql(peer));
}
test "a trusted peer is identified by the last forwarded-for entry" {
const peer = ip("10.0.0.1");
const derived = clientAddr("10.0.0.1, 10.0.0.2", peer, "203.0.113.9, 198.51.100.7");
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
}
test "a trusted peer that forwards nothing stays itself" {
const peer = ip("10.0.0.1");
try testing.expect(clientAddr("10.0.0.1", peer, null).addr.eql(peer));
}
test "an IPv6 proxy and an IPv6 forwarded entry work the same way" {
const peer = ip("fd00::1");
const derived = clientAddr("fd00::1", peer, "2001:db8::5, 2001:db8::7");
try testing.expect(derived.addr.eql(ip("2001:db8::7")));
}
test "an oversized forwarded-for keeps the entry the proxy appended" {
var header: std.ArrayList(u8) = .empty;
defer header.deinit(testing.allocator);
while (header.items.len < 4096) try header.appendSlice(testing.allocator, "203.0.113.9, ");
try header.appendSlice(testing.allocator, "198.51.100.7");
// What the connection would copy: the tail alone, the head thrown away.
const tail = header.items[header.items.len - http_util.max_xff_len ..];
const derived = clientAddr("10.0.0.1", ip("10.0.0.1"), tail);
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
}
test "a trusted peer whose forwarded-for has no valid last entry is refused" {
const peer = ip("10.0.0.1");
try testing.expectEqual(
ClientAddr.bad_forwarded_for,
std.meta.activeTag(clientAddr("10.0.0.1", peer, "203.0.113.9, not-an-ip")),
);
try testing.expectEqual(
ClientAddr.bad_forwarded_for,
std.meta.activeTag(clientAddr("10.0.0.1", peer, "")),
);
// An oversized header whose tail cuts the last entry in half is the same
// failure, and must not degrade to the exemptible socket peer.
try testing.expectEqual(
ClientAddr.bad_forwarded_for,
std.meta.activeTag(clientAddr("10.0.0.1", peer, "8.51.100.7000")),
);
}
test "a trusted-proxy element that is not an IP literal trusts nobody" {
const peer = ip("10.0.0.1");
try testing.expect(!trustsPeer("proxy.example", peer));
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
try testing.expect(!trustsPeer("", peer));
}