milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
+93
-9
@@ -51,6 +51,8 @@ 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);
|
||||
|
||||
@@ -151,6 +153,10 @@ pub const WebState = struct {
|
||||
/// (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,
|
||||
@@ -357,6 +363,12 @@ pub const Server = struct {
|
||||
log.debug("web listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// Ruling 11 of milestone 16, before `beginShutdown`: 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 below waits out one heartbeat interval per idle stream.
|
||||
if (self.state.hub) |hub| hub.close(io);
|
||||
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
@@ -507,7 +519,7 @@ pub const Server = struct {
|
||||
const raw_path = copied[0..split];
|
||||
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
|
||||
|
||||
const cookie = copyHeader(request, "cookie", &conn.cookie_buf);
|
||||
const cookie = copyCookie(request, &conn.cookie_buf);
|
||||
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);
|
||||
|
||||
@@ -606,18 +618,60 @@ pub const Server = struct {
|
||||
};
|
||||
|
||||
/// Copies one header value into `buf`. A value too long for its budget reads as
|
||||
/// absent: the three headers this applies to are a session cookie, an
|
||||
/// `accept-encoding` and an `if-none-match`, and losing any of them degrades to
|
||||
/// unauthenticated, uncompressed and unconditional — never to a wrong answer.
|
||||
/// 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];
|
||||
}
|
||||
|
||||
/// 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)) continue;
|
||||
if (header.value.len > buf.len) return "";
|
||||
@memcpy(buf[0..header.value.len], header.value);
|
||||
return buf[0..header.value.len];
|
||||
if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value;
|
||||
}
|
||||
return "";
|
||||
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 whole claim rule, without the mutex, so it is testable without a backend.
|
||||
@@ -697,6 +751,36 @@ test "the over-capacity response is a well formed 503" {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user