//! The admin HTTP listener. //! //! One `std.http.Server` per connection over the shared `listener.Core` accept //! loop (milestone-18 ruling 1): a listener task in the app's group, an inner //! `Io.Group` of connection tasks, and a keep-alive loop per connection that //! ends on `error.HttpConnectionClosing`. The shape is //! lib/std/Build/WebServer.zig:152-185; the slot pool and the shutdown split //! come from the core, which documents both. //! //! Connection slots are fixed and pre-allocated, and each one owns every buffer //! a request needs, so serving allocates only what a handler asks the //! per-request arena for. Over capacity the listener answers 503 and closes //! (ruling 7) rather than queueing: refusing is honest, a queue would hide it. //! //! There is no per-request timeout this milestone. The port is LAN-facing and //! behind the operator's own network; the cancel path, not a timer, is what //! bounds shutdown. A slow client costs one of 64 slots and nothing else. const std = @import("std"); const net = std.Io.net; const http = std.http; const Allocator = std.mem.Allocator; const address = @import("../platform/address.zig"); const api_limiter = @import("api_limiter.zig"); const auth = @import("auth.zig"); const cert_store = @import("../server/cert_store.zig"); const client_names = @import("../server/client_names.zig"); const clients = @import("../server/clients.zig"); const db = @import("../storage/db.zig"); const disk_monitor = @import("../storage/disk_monitor.zig"); const dns_handler = @import("../server/handler.zig"); const doh_server = @import("../server/doh_server.zig"); const dot_server = @import("../server/dot_server.zig"); const http_util = @import("http_util.zig"); const listener_core = @import("../server/listener.zig"); const local_tables_mod = @import("../server/local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); const manager_mod = @import("../filter/manager.zig"); const model = @import("../config/model.zig"); const pause_mod = @import("../server/pause.zig"); const pool_mod = @import("../upstream/pool.zig"); const query_sink = @import("../server/query_sink.zig"); const retention_mod = @import("../storage/retention.zig"); const router = @import("router.zig"); const sse = @import("sse.zig"); const tcp_server = @import("../server/tcp_server.zig"); const udp_server = @import("../server/udp_server.zig"); const log = std.log.scoped(.web_server); /// Ruling 7. The receive buffer is also the maximum request head /// (http/Server.zig:32 sets `max_head_len` from it). const recv_buffer_len = 8 * 1024; const send_buffer_len = 4 * 1024; /// 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 /// a normal API response allocates nothing new, small enough that 64 idle /// connections cost 4 MiB rather than 64. const arena_retain_bytes = 64 * 1024; const over_capacity_body = "{\"error\":\"too many connections\"}"; const over_capacity_response = std.fmt.comptimePrint( "HTTP/1.1 503 Service Unavailable\r\n" ++ "content-type: " ++ http_util.content_type_json ++ "\r\n" ++ "connection: close\r\n" ++ "content-length: {d}\r\n\r\n{s}", .{ over_capacity_body.len, over_capacity_body }, ); /// The verdict of an API rate-limit check. The limiter's own result type, not a /// copy of it: two structurally identical verdicts would only drift. pub const LimitVerdict = api_limiter.Result; pub const AuthCheckFn = *const fn ( state: *WebState, io: std.Io, request: *const http_util.Request, ) bool; pub const LimitCheckFn = *const fn ( state: *WebState, io: std.Io, request: *const http_util.Request, ) LimitVerdict; /// Applies a configuration change to the running server (ruling 12: rules, /// blocklists, groups, clients and prefixes take effect live). Mutation /// handlers call it through this pointer so their tests can count the calls /// without a real `Manager`. pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void; /// Everything the web layer borrows, assembled by the composition root. Every /// pointer here outlives the listener task: `app.serve` declares the /// collaborators above the task group and cancels the group before releasing /// any of them. /// /// The collaborator pointers are optional because the web layer must build and /// be testable without a whole running server, and because `web.enabled = /// false` means several of them are never opened at all (ruling 6). A handler /// that finds the collaborator it needs missing answers 503, the same way it /// answers a missing snapshot. /// Which of the two sources governs this process's configuration (milestone-20 /// ruling 1). Per-process state, never persisted: authority lives in the /// invocation, and the database carries no record of who wrote it. /// /// The `managed_file` path is owned by `serve`'s arena, which outlives every /// `WebState`, so nothing here copies it. pub const Authority = union(enum) { database, managed_file: []const u8, }; pub const WebState = struct { gpa: Allocator, web: model.Web = .{}, /// Defaults to `.database`: a `WebState` nobody told about a managed file /// governs nothing declaratively, which is the safe reading — the mutation /// routes stay live rather than a half-wired server refusing every write. authority: Authority = .database, /// When this process loaded the managed file, in epoch seconds. Null in /// database mode, which never reconciles. It answers exactly "this process /// loaded the file at T" and nothing more: a file whose mtime is newer has /// not been loaded by the running process. reconciled_at: ?i64 = null, handler: ?*dns_handler.Handler = null, pause: ?*pause_mod.Pause = null, tracker: ?*clients.Tracker = null, /// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9). client_names: ?*client_names.Resolver = null, manager: ?*manager_mod.Manager = null, pool: ?*pool_mod.Pool = null, monitor: ?*disk_monitor.Monitor = null, /// The local records and forward zones the DNS path reads. The /// local-records and forward-zones handlers rebuild and swap them /// (ruling 12). local_tables: ?*local_tables_mod.LocalTables = null, logger: ?*logger_mod.Logger = null, retention: ?*retention_mod.Retention = null, sessions: ?*auth.Sessions = null, /// The password hash every auth decision reads. `web` above is the boot /// configuration and goes stale the moment `PUT /api/settings` changes the /// password; this holder is what makes the revoked credential stop working /// without a restart. The composition root seeds it from the boot hash, /// the settings handler installs replacements, and whoever owns the /// `WebState` calls `live_hash.deinit`. live_hash: auth.LiveHash = .{}, limiter: ?*api_limiter.ApiLimiter = null, /// The SSE fanout. The sink publishes into it on the DNS hot path; the /// live-query handler subscribes. hub: ?*sse.Hub = null, sink: ?*query_sink.QuerySink = null, /// The DoH/DoT certificate stores; null while an endpoint is disabled. /// `POST /api/certs/reload` reloads through these, and `/metrics` reads /// their counters (milestone-10 rulings 8 and 10). doh_certs: ?*cert_store.CertStore = null, dot_certs: ?*cert_store.CertStore = null, /// The DoH/DoT listeners themselves; null while an endpoint is disabled /// or its bind failed. `/metrics` reads their connection counters /// (milestone-10 ruling 10). doh_listener: ?*doh_server.DohServer = null, dot_listener: ?*dot_server.DotServer = null, /// The plain DNS listeners. The app binds one per family per protocol, so /// `/metrics` sums each family across its slice (milestone-16 ruling 13). udp_listeners: []const *udp_server.UdpServer = &.{}, tcp_listeners: []const *tcp_server.TcpServer = &.{}, /// The web task's own connections (m7 ruling 21) — never the DNS path's. config_db: ?*db.Db = null, /// Serializes the mutation handlers' work on `config_db`. Connection tasks /// share the one connection, and `changes()` and `lastInsertRowid()` are /// connection state that the repositories read after a write, so two /// concurrent writes would misread each other's row counts. config_lock: std.Io.Mutex = .init, querylog_db: ?*db.Db = null, version: []const u8 = "", /// The `--admin-dev` asset directory, read by the dev-mode fallback. Empty /// whenever that fallback is not wired. dev_dir: []const u8 = "", /// Unix seconds at process start, for uptime. started_unix: i64 = 0, /// The table `dispatch` matches against. Defaults to the shipped one; /// tests point it at their own. routes: []const router.RouteInfo = router.routes, /// Answers a path no route claimed and that is not under `/api` — the /// static assets and the SPA fallback (ruling 24). Null means every miss is /// a JSON 404. fallback: ?router.HandlerFn = null, /// The three policy seams. They are function pointers so that the tests in /// this layer can drive authentication, rate limiting and reload with /// doubles instead of a real session store, a real clock and a real /// `Manager`. The defaults are the production implementations, so the /// composition root wires collaborators rather than behaviour, and a /// forgotten wire fails closed rather than open. This is the only /// indirection of its kind in the web layer; everything else is a direct /// call. check_auth: AuthCheckFn = sessionAuth, check_limit: LimitCheckFn = bucketLimit, reload_fn: ?ReloadFn = null, }; /// Ruling 17. Authentication is enabled iff a password hash is set — the live /// one, so a password set through the API locks the routes without a restart. /// With it set but no session store wired, every session route is refused: the /// failure mode of a half-wired server must be locked, not open. pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Request) bool { if (!state.live_hash.enabled(io)) return true; const sessions = state.sessions orelse return false; const cookie = http_util.cookieValue(request.cookie, auth.cookie_name) orelse return false; return sessions.validate(io, cookie); } /// 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.client_addr)); } /// Seam double: refuses nothing. For tests and for a server with no admin /// password, where `sessionAuth` already answers the same way. pub fn allowAll(state: *WebState, io: std.Io, request: *const http_util.Request) bool { _ = state; _ = io; _ = request; return true; } /// Seam double: throttles nothing. pub fn neverLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict { _ = state; _ = io; _ = request; return .ok; } /// What the admin listener counts on top of `listener.CoreStats`. Nothing /// exports these: there is no `nxdns_web_*` family, they exist for the /// integration tests and for a future one. pub const Stats = struct { requests: std.atomic.Value(u64) = .init(0), }; pub const Options = struct { max_connections: u16 = default_max_connections, }; pub const Server = struct { core: listener_core.Core(Config), state: *WebState, stats: Stats, /// One slot's fixed cost. The head copies exist because every string in /// `request.head` dies on the first body read (http/Server.zig:594). The /// receive and send buffers belong to the core. pub const Payload = struct { target_buf: [http_util.max_target_len]u8, 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, fn init(payload: *Payload, gpa: Allocator) void { payload.arena = .init(gpa); } fn deinit(payload: *Payload) void { payload.arena.deinit(); } }; const Config = struct { pub const Owner = Server; pub const ConnPayload = Payload; pub const serveConn = serveOne; pub const read_buffer_len = recv_buffer_len; pub const write_buffer_len = send_buffer_len; pub const log = std.log.scoped(.web_server); pub const name = "web"; pub const refuse = refuseOverCapacity; pub const initPayload = Payload.init; pub const deinitPayload = Payload.deinit; }; pub const Conn = listener_core.Core(Config).Conn; pub const ListenError = listener_core.Core(Config).ListenError; pub fn listen( gpa: Allocator, io: std.Io, listen_address: net.IpAddress, state: *WebState, options: Options, ) ListenError!Server { return .{ .core = try listener_core.Core(Config).listen(gpa, io, listen_address, options.max_connections), .state = state, .stats = .{}, }; } /// The kernel-assigned address. A port of 0 in `listen` resolves here. pub fn boundAddress(self: *const Server) net.IpAddress { return self.core.boundAddress(); } /// Accept loop. Returns when the task is canceled or `deinit` stops it. pub fn serve(self: *Server, io: std.Io) void { self.core.serve(io); } pub fn deinit(self: *Server, io: std.Io) void { // Ruling 11 of milestone 16, before the core shuts the connections // down: a live-query task parked in `Hub.wait` is waiting on an event, // not on its socket, so shutting the connection down does not reach it. // Without this the drain waits out one heartbeat interval per idle // stream. if (self.state.hub) |hub| hub.close(io); self.core.deinit(io); self.* = undefined; } /// Ruling 7: over capacity the client is told so, never silently dropped. /// /// The response is written from the accept loop, because refusing must not /// consume the slot that is missing. It is ~130 bytes — one socket buffer — /// so a peer that never reads still cannot stall the loop. /// /// The close that follows does not drain the client's request first, so /// Linux may follow the response with an RST and a client that had already /// sent its request can lose the 503 and see a reset instead. Draining /// would mean a blocking read on the accept loop with no bound but the /// client's goodwill, which is a worse failure than a lost error page on a /// server that is already at capacity. fn refuseOverCapacity(io: std.Io, stream: net.Stream) void { var buf: [over_capacity_response.len]u8 = undefined; var writer = stream.writer(io, &buf); writer.interface.writeAll(over_capacity_response) catch {}; writer.interface.flush() catch {}; stream.close(io); } /// One connection's keep-alive loop. The core closes the slot when this /// returns. fn serveOne(self: *Server, io: std.Io, index: usize) void { const conn = &self.core.conns[index]; var reader = conn.stream.reader(io, &conn.read_buf); var writer = conn.stream.writer(io, &conn.write_buf); var connection: http.Server = .init(&reader.interface, &writer.interface); while (connection.reader.state == .ready) { var request = connection.receiveHead() catch |err| switch (err) { // The normal end of a keep-alive connection. error.HttpConnectionClosing => return, // Cancellation and a vanished client both land here; neither is // worth a counter. error.ReadFailed => return, error.HttpHeadersOversize => { listener_core.bump(&self.core.stats.connection_errors); return; }, error.HttpRequestTruncated, error.HttpHeadersInvalid => { listener_core.bump(&self.core.stats.connection_errors); return; }, }; // RFC 9110 §8.6: a request with neither content-length nor // transfer-encoding has an empty body, but std leaves the head // saying "unknown" and `discardBody` asserts on it inside every // `respond` (http/Server.zig:631) — `curl -X POST` panics the // process. A zero length is what the head means, and it satisfies // every downstream reader: `bodyReader` (http.zig:445) goes // straight to `.ready` on a zero content-length. if (request.head.method.requestHasBody() and request.head.transfer_encoding == .none and request.head.content_length == null) { request.head.content_length = 0; } listener_core.bump(&self.stats.requests); // Retained with a limit, not wholesale: a single 1 MiB body would // otherwise keep a megabyte per slot alive for as long as the // browser holds the connection. _ = conn.payload.arena.reset(.{ .retain_with_limit = arena_retain_bytes }); self.handleRequest(io, conn, &request) catch |err| switch (err) { // Ruling 28: the peer went away mid-response. Normal. error.WriteFailed => return, error.HttpExpectationFailed, error.OutOfMemory => { listener_core.bump(&self.core.stats.connection_errors); return; }, }; } } /// Builds the request view and dispatches it. Every string a handler may /// touch after a body read is copied here first (ruling 25). fn handleRequest( self: *Server, io: std.Io, conn: *Conn, request: *http.Server.Request, ) http_util.HandlerError!void { const arena = conn.payload.arena.allocator(); const target = request.head.target; if (target.len > conn.payload.target_buf.len) { var view = bareRequest(request, conn, arena); return http_util.respondError(&view, .uri_too_long, "target too long"); } @memcpy(conn.payload.target_buf[0..target.len], target); const copied = conn.payload.target_buf[0..target.len]; const split = std.mem.findScalar(u8, copied, '?') orelse copied.len; const raw_path = copied[0..split]; const query = if (split == copied.len) copied[split..] else copied[split + 1 ..]; const cookie = copyCookie(request, &conn.payload.cookie_buf); const accept_encoding = copyHeader(request, "accept-encoding", &conn.payload.accept_encoding_buf); const if_none_match = copyHeader(request, "if-none-match", &conn.payload.if_none_match_buf); const peer = address.NetAddress.fromIp(conn.peer); const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.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; const path = http_util.parsePath(decodable) catch { var view = bareRequest(request, conn, arena); return http_util.respondError(&view, .bad_request, "malformed path"); }; var view: http_util.Request = .{ .http = request, .method = request.head.method, .path = path, .raw_path = raw_path, .query = query, .id = null, .cookie = cookie, .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); } /// A request view for the errors that are decided before parsing finishes. fn bareRequest(request: *http.Server.Request, conn: *Conn, arena: Allocator) http_util.Request { return .{ .http = request, .method = request.head.method, .path = .empty, .raw_path = "", .query = "", .id = null, .cookie = "", .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, }; } }; /// Copies one header value into `buf`. A value too long for its budget reads as /// absent: the headers this applies to are an `accept-encoding` and an /// `if-none-match`, and losing either degrades to uncompressed and /// unconditional — never to a wrong answer. The cookie header has its own /// copier, because losing it costs the session (ruling 7 of milestone 16). fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []const u8 { const value = headerValue(request, name) orelse return ""; if (value.len > buf.len) return ""; @memcpy(buf[0..value.len], value); 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(); while (it.next()) |header| { if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value; } return null; } /// Milestone-16 ruling 7. The cookie header is the one budget a foreign party /// can spend: behind a reverse proxy on a shared domain, every other cookie set /// for the domain rides along. Treating the whole header as absent then logs the /// operator out of a working session with nothing in the log to explain it, so /// an oversized header keeps the session pair, drops the rest, and says so. fn copyCookie(request: *http.Server.Request, buf: []u8) []const u8 { const value = headerValue(request, "cookie") orelse return ""; if (value.len <= buf.len) { @memcpy(buf[0..value.len], value); return buf[0..value.len]; } const kept = sessionPairOnly(value, buf); // The session value is a random id and the name is a constant, so neither // the size nor the outcome discloses anything the client did not send. log.debug("cookie header of {d} bytes exceeds the {d} byte budget; {s}", .{ value.len, buf.len, if (kept.len == 0) "no session cookie kept" else "kept the session cookie alone", }); return kept; } /// Rewrites an oversized cookie header as just its session pair. Empty when the /// header carries no session cookie, or when even the pair is over budget. fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 { const session = http_util.cookieValue(value, auth.cookie_name) orelse return ""; const len = auth.cookie_name.len + 1 + session.len; if (len > buf.len) return ""; @memcpy(buf[0..auth.cookie_name.len], auth.cookie_name); buf[auth.cookie_name.len] = '='; @memcpy(buf[auth.cookie_name.len + 1 ..][0..session.len], session); return buf[0..len]; } /// The composition root's entry point: bind, serve, release. /// /// A bind failure is warned and swallowed. The admin UI failing to come up must /// not stop nxdns answering DNS, which is what the box is for; the operator /// sees the warning and the DNS side keeps serving. pub fn serve(state: *WebState, io: std.Io) void { const bind_address = net.IpAddress.parse(state.web.bind, state.web.port) catch { log.warn("web.bind '{s}' is not an IP address; the web interface is disabled", .{state.web.bind}); return; }; var server: Server = Server.listen(state.gpa, io, bind_address, state, .{}) catch |err| { log.warn("web interface cannot listen on {s}:{d}: {t}", .{ state.web.bind, state.web.port, err }); return; }; defer server.deinit(io); log.info("web interface listening on {f}", .{server.boundAddress()}); server.serve(io); } const testing = std.testing; test "the over-capacity response is a well formed 503" { try testing.expect(std.mem.startsWith(u8, over_capacity_response, "HTTP/1.1 503 ")); const split = std.mem.findPosLinear(u8, over_capacity_response, 0, "\r\n\r\n").?; try testing.expectEqualStrings(over_capacity_body, over_capacity_response[split + 4 ..]); } test "an oversized cookie header keeps its session pair and nothing else" { var buf: [http_util.max_cookie_len]u8 = undefined; var header: std.ArrayList(u8) = .empty; defer header.deinit(testing.allocator); try header.appendSlice(testing.allocator, "consent=yes; "); try header.appendSlice(testing.allocator, auth.cookie_name ++ "=abc123; "); while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; "); const kept = sessionPairOnly(header.items, &buf); try testing.expectEqualStrings(auth.cookie_name ++ "=abc123", kept); try testing.expectEqualStrings("abc123", http_util.cookieValue(kept, auth.cookie_name).?); } test "an oversized cookie header with no session pair keeps nothing" { var buf: [http_util.max_cookie_len]u8 = undefined; var header: std.ArrayList(u8) = .empty; defer header.deinit(testing.allocator); while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; "); try testing.expectEqualStrings("", sessionPairOnly(header.items, &buf)); } test "a session pair too long for the buffer keeps nothing" { var buf: [32]u8 = undefined; const header = auth.cookie_name ++ "=" ++ ("v" ** 64); try testing.expectEqualStrings("", sessionPairOnly(header, &buf)); } test "an unconfigured password leaves every route open" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); var state: WebState = .{ .gpa = testing.allocator }; const request = testRequest(); try testing.expect(sessionAuth(&state, threaded.io(), &request)); } test "a configured password with no session store refuses rather than opens" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); var state: WebState = .{ .gpa = testing.allocator, .live_hash = .init("$argon2id$...") }; const request = testRequest(); try testing.expect(!sessionAuth(&state, threaded.io(), &request)); } test "an unwired limiter throttles nothing" { var state: WebState = .{ .gpa = testing.allocator }; const request = testRequest(); try testing.expect(bucketLimit(&state, undefined, &request).allowed); } test "the seam doubles are usable in place of the production checks" { var state: WebState = .{ .gpa = testing.allocator, .check_auth = allowAll, .check_limit = neverLimit }; const request = testRequest(); try testing.expect(state.check_auth(&state, undefined, &request)); try testing.expect(state.check_limit(&state, undefined, &request).allowed); } /// `io` is never reached on these paths, so the tests above pass `undefined`. fn testRequest() http_util.Request { return .{ .http = undefined, .method = .GET, .path = .empty, .raw_path = "/api/groups", .query = "", .id = null, .cookie = "", .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)); }