milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
This commit is contained in:
+155
-2
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user