From a246a67d9312c0fce842d9ce5a734c651cdc6b4b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 10 Sep 2026 11:42:06 -0700 Subject: [PATCH 01/21] add CorsStore --- src/Metrics.zig | 2 +- src/network/CorsGate.zig | 35 ++++ src/network/CorsStore.zig | 348 +++++++++++++++++++++++++++++++++++++ src/network/HttpClient.zig | 5 +- src/network/Network.zig | 5 + 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 src/network/CorsStore.zig diff --git a/src/Metrics.zig b/src/Metrics.zig index c7dabee96..c92278d6f 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -93,7 +93,7 @@ http_navigation_delay_ms: Histogram(&.{ robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, robots_access: CounterEnum("result", enum { allow, deny }) = .{}, robots_evictions: Counter = .{}, -cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight }) = .{}, +cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight, cached }) = .{}, cors_preflight: CounterEnum("result", enum { allowed, blocked }) = .{}, cors_response: CounterEnum("result", enum { allowed, blocked }) = .{}, adblock_verdicts: CounterEnum("verdict", @import("network/adblock/AdBlocker.zig").Verdict) = .{}, diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 7302ab3b9..ba9310eb8 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -25,11 +25,15 @@ const http = @import("http.zig"); const Transfer = @import("HttpClient.zig").Transfer; const SingleFlight = @import("SingleFlight.zig"); const HttpClient = @import("HttpClient.zig"); +const Network = @import("Network.zig"); + +const CorsStore = @import("CorsStore.zig"); const log = lp.log; const CorsGate = @This(); +network: *Network, single_flight: SingleFlight, // CORS Request Headers @@ -212,6 +216,22 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .allowed; } + if (try URL.getOrigin(transfer.arena.allocator(), req.url)) |target| { + if (self.network.cors_store.get(.{ .origin = origin, .target = target })) |cached| { + const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); + if (CorsStore.covers(cached, req.method, req.credentials_mode == .include, authored.items)) { + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = origin, + .preflight = false, + .cached = true, + }); + lp.metrics.cors_check.incr(.cached); + return .allowed; + } + } + } + log.debug(.cors, "cross origin", .{ .url = req.url, .origin = origin, @@ -223,6 +243,21 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .pending; } +fn collectAuthoredHeaders(transfer: *Transfer, allocator: std.mem.Allocator) !std.ArrayList([]const u8) { + var header_names: std.ArrayList([]const u8) = .empty; + for (transfer.req_headers.items) |hdr| { + if (hdr.source != .author) continue; + if (isSafelistedHeader(hdr.name, hdr.value)) continue; + try header_names.append(allocator, try std.ascii.allocLowerString(allocator, hdr.name)); + } + std.mem.sort([]const u8, header_names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + return header_names; +} + const CorsKey = struct { url: []const u8, origin: []const u8, diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig new file mode 100644 index 000000000..696c5676a --- /dev/null +++ b/src/network/CorsStore.zig @@ -0,0 +1,348 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const http = @import("http.zig"); + +const CorsStore = @This(); + +const Key = struct { + origin: []const u8, + target: []const u8, + + fn dupe(self: Key, allocator: std.mem.Allocator) !Key { + return .{ + .origin = try allocator.dupe(u8, self.origin), + .target = try allocator.dupe(u8, self.target), + }; + } + + fn deinit(self: Key, allocator: std.mem.Allocator) void { + allocator.free(self.origin); + allocator.free(self.target); + } +}; + +const KeyContext = struct { + pub fn hash(_: KeyContext, key: Key) u64 { + var hasher = std.hash.Wyhash.init(0); + hasher.update(key.origin); + hasher.update(&.{0}); + hasher.update(key.target); + return hasher.final(); + } + + pub fn eql(_: KeyContext, a: Key, b: Key) bool { + return std.ascii.eqlIgnoreCase(a.origin, b.origin) and std.ascii.eqlIgnoreCase(a.target, b.target); + } +}; + +const Entry = struct { + methods_wildcard: bool, + methods: std.EnumSet(http.Method), + + headers_wildcard: bool, + headers: []const []const u8, + + expires_at: u64, + + credentials: bool, + + fn unionHeaders( + allocator: std.mem.Allocator, + a: []const []const u8, + b: []const []const u8, + ) ![]const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + errdefer { + for (list.items) |s| allocator.free(s); + list.deinit(allocator); + } + + outerA: for (a) |s| { + for (list.items) |existing| { + if (std.ascii.eqlIgnoreCase(existing, s)) continue :outerA; + } + try list.append(allocator, try allocator.dupe(u8, s)); + } + outerB: for (b) |s| { + for (list.items) |existing| { + if (std.ascii.eqlIgnoreCase(existing, s)) continue :outerB; + } + try list.append(allocator, try allocator.dupe(u8, s)); + } + + return list.toOwnedSlice(allocator); + } + + fn merge(self: Entry, allocator: std.mem.Allocator, new: Entry) !Entry { + return .{ + .credentials = self.credentials or new.credentials, + .methods_wildcard = self.methods_wildcard or new.methods_wildcard, + .methods = self.methods.unionWith(new.methods), + .headers_wildcard = self.headers_wildcard or new.headers_wildcard, + .headers = try unionHeaders(allocator, self.headers, new.headers), + .expires_at = @max(self.expires_at, new.expires_at), + }; + } + + fn dupe(self: Entry, allocator: std.mem.Allocator) !Entry { + var new_headers: std.ArrayList([]const u8) = try .initCapacity(allocator, self.headers.len); + errdefer { + for (new_headers.items) |hdr| allocator.free(hdr); + new_headers.deinit(allocator); + } + + for (self.headers) |hdr| { + new_headers.appendAssumeCapacity(try allocator.dupe(u8, hdr)); + } + + return .{ + .methods_wildcard = self.methods_wildcard, + .methods = self.methods, + .headers_wildcard = self.headers_wildcard, + .headers = new_headers.items, + .expires_at = self.expires_at, + .credentials = self.credentials, + }; + } + + fn deinit(self: Entry, allocator: std.mem.Allocator) void { + for (self.headers) |h| allocator.free(h); + allocator.free(self.headers); + } +}; + +const Map = std.HashMapUnmanaged(Key, Entry, KeyContext, std.hash_map.default_max_load_percentage); + +allocator: std.mem.Allocator, +map: Map = .empty, +mutex: std.Io.Mutex = .init, + +pub fn init(allocator: std.mem.Allocator) CorsStore { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *CorsStore) void { + self.mutex.lockUncancelable(lp.io); + defer self.mutex.unlock(lp.io); + + var iter = self.map.iterator(); + while (iter.next()) |entry| { + entry.key_ptr.deinit(self.allocator); + entry.value_ptr.deinit(self.allocator); + } + + self.map.deinit(self.allocator); +} + +pub fn get(self: *CorsStore, key: Key) ?Entry { + self.mutex.lockUncancelable(lp.io); + defer self.mutex.unlock(lp.io); + + const entry = self.map.get(key) orelse return null; + + if (entry.expires_at <= lp.datetime.timestamp(.real)) { + const kv = self.map.fetchRemove(key).?; + kv.key.deinit(self.allocator); + kv.value.deinit(self.allocator); + return null; + } + + return entry; +} + +/// Insert or merge a CORS grant for (origin, target). `entry` is not +/// consumed: `put` copies whatever it needs (via `dupe`/`merge`, which +/// always allocate their own copies) and never takes ownership of +/// `entry.headers` or its contents. +/// +/// Callers remain responsible for +/// freeing `entry.headers` after this call, on both the insert and +/// the merge path. +pub fn put(self: *CorsStore, key: Key, entry: Entry) !void { + self.mutex.lockUncancelable(lp.io); + defer self.mutex.unlock(lp.io); + + const gop = try self.map.getOrPut(self.allocator, key); + if (!gop.found_existing) { + errdefer _ = self.map.remove(key); + gop.key_ptr.* = try key.dupe(self.allocator); + gop.value_ptr.* = try entry.dupe(self.allocator); + return; + } + + const old = gop.value_ptr.*; + const merged = old.merge(self.allocator, entry) catch |err| { + return err; + }; + old.deinit(self.allocator); + gop.value_ptr.* = merged; +} + +pub fn covers( + entry: Entry, + method: http.Method, + wants_credentials: bool, + authored_headers: []const []const u8, +) bool { + if (wants_credentials and !entry.credentials) { + return false; + } + + if (!entry.methods_wildcard and !entry.methods.contains(method)) { + return false; + } + + for (authored_headers) |name| { + const is_authorization = std.ascii.eqlIgnoreCase(name, "authorization"); + if (entry.headers_wildcard and !is_authorization) continue; + + var found = false; + for (entry.headers) |allowed| { + if (std.ascii.eqlIgnoreCase(allowed, name)) { + found = true; + break; + } + } + if (!found) return false; + } + + return true; +} + +const testing = @import("../testing.zig"); + +fn freeHeaders(allocator: std.mem.Allocator, headers: []const []const u8) void { + for (headers) |h| allocator.free(h); + allocator.free(headers); +} + +test "CorsStore: put then get, miss on different origin/target" { + const allocator = testing.allocator; + var store = CorsStore.init(allocator); + defer store.deinit(); + + const headers = try allocator.alloc([]const u8, 1); + headers[0] = try allocator.dupe(u8, "x-custom"); + defer freeHeaders(allocator, headers); + + try store.put(.{ .origin = "https://a.example", .target = "https://api.example" }, .{ + .credentials = false, + .methods_wildcard = false, + .methods = std.EnumSet(http.Method).initOne(.POST), + .headers_wildcard = false, + .headers = headers, + .expires_at = lp.datetime.timestamp(.real) + 60_000, + }); + + // store.get returns the store's own copy — not caller-owned, don't free it. + const hit = store.get(.{ .origin = "https://a.example", .target = "https://api.example" }).?; + try testing.expect(hit.methods.contains(.POST)); + try testing.expect(!hit.methods.contains(.GET)); + + try testing.expectEqual(null, store.get(.{ .origin = "https://b.example", .target = "https://api.example" })); + try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://other.example" })); +} + +test "CorsStore: expired entries are evicted on get" { + const allocator = testing.allocator; + var store = CorsStore.init(allocator); + defer store.deinit(); + + try store.put(.{ .origin = "https://a.example", .target = "https://api.example" }, .{ + .credentials = false, + .methods_wildcard = true, + .methods = .initEmpty(), + .headers_wildcard = true, + .headers = &.{}, // empty slice, nothing to free + .expires_at = lp.datetime.timestamp(.real) - 1, + }); + + try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example" })); + try testing.expectEqual(0, store.map.count()); +} + +test "CorsStore: put merges into existing entry rather than clobbering" { + const allocator = testing.allocator; + var store = CorsStore.init(allocator); + defer store.deinit(); + + const key = Key{ .origin = "https://a.example", .target = "https://api.example" }; + + const h1 = try allocator.alloc([]const u8, 1); + h1[0] = try allocator.dupe(u8, "x-one"); + try store.put(key, .{ + .credentials = false, + .methods_wildcard = false, + .methods = std.EnumSet(http.Method).initOne(.POST), + .headers_wildcard = false, + .headers = h1, + .expires_at = lp.datetime.timestamp(.real) + 60_000, + }); + freeHeaders(allocator, h1); + + const h2 = try allocator.alloc([]const u8, 1); + h2[0] = try allocator.dupe(u8, "x-two"); + try store.put(key, .{ + .credentials = false, + .methods_wildcard = false, + .methods = std.EnumSet(http.Method).initOne(.PUT), + .headers_wildcard = false, + .headers = h2, + .expires_at = lp.datetime.timestamp(.real) + 60_000, + }); + freeHeaders(allocator, h2); + + const merged = store.get(key).?; + try testing.expect(merged.methods.contains(.POST)); + try testing.expect(merged.methods.contains(.PUT)); + try testing.expectEqual(2, merged.headers.len); + + try testing.expect(CorsStore.covers(merged, .POST, false, &.{"x-one"})); + try testing.expect(CorsStore.covers(merged, .PUT, false, &.{"x-two"})); + try testing.expect(!CorsStore.covers(merged, .DELETE, false, &.{})); +} + +test "CorsStore: covers rejects credentialed request against uncredentialed wildcard" { + const entry = CorsStore.Entry{ + .credentials = false, + .methods_wildcard = true, + .methods = .initEmpty(), + .headers_wildcard = true, + .headers = &.{}, + .expires_at = std.math.maxInt(i64), + }; + try testing.expect(!CorsStore.covers(entry, .GET, true, &.{})); + try testing.expect(CorsStore.covers(entry, .GET, false, &.{})); +} + +test "CorsStore: covers never lets a wildcard cover Authorization" { + const entry = CorsStore.Entry{ + .credentials = false, + .methods_wildcard = true, + .methods = .initEmpty(), + .headers_wildcard = true, + .headers = &.{}, + .expires_at = std.math.maxInt(i64), + }; + try testing.expect(!CorsStore.covers(entry, .GET, false, &.{"authorization"})); + try testing.expect(CorsStore.covers(entry, .GET, false, &.{"x-anything"})); +} diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 45e155065..a1ee7bcdc 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -243,7 +243,10 @@ pub fn init(self: *Client, app: *lp.App) !void { .network = network, .single_flight = .init(allocator), }, - .cors = .{ .single_flight = .init(allocator) }, + .cors = .{ + .network = network, + .single_flight = .init(allocator), + }, .url_blocklist = url_blocklist, .arena_pool = &app.arena_pool, }; diff --git a/src/network/Network.zig b/src/network/Network.zig index f6593c008..7a78d5a24 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -27,6 +27,7 @@ const libcurl = @import("../sys/libcurl.zig"); const http = @import("http.zig"); const IpFilter = @import("IpFilter.zig"); const RobotStore = @import("Robots.zig").RobotStore; +const CorsStore = @import("CorsStore.zig"); const WebBotAuth = @import("WebBotAuth.zig"); const RateLimiter = @import("RateLimiter.zig"); const Certificates = @import("Certificates.zig"); @@ -43,6 +44,7 @@ cache: Cache, allocator: Allocator, config: *const Config, robot_store: RobotStore, +cors_store: CorsStore, web_bot_auth: ?WebBotAuth, rate_limiter: ?RateLimiter, certificates: Certificates, @@ -122,6 +124,7 @@ pub fn init(app: *App) !Network { .cache = cache, .robot_store = RobotStore.init(allocator, config.robotStoreEntryLimit()), + .cors_store = CorsStore.init(allocator), .web_bot_auth = web_bot_auth, .rate_limiter = if (config.httpNavDelay()) |ms| RateLimiter.init(allocator, ms, config.httpNavBurst()) else null, .adblocker = adblocker, @@ -144,6 +147,8 @@ pub fn deinit(self: *Network) void { self.ws_pool.deinit(self.allocator); self.robot_store.deinit(); + self.cors_store.deinit(); + if (self.rate_limiter) |*rl| { rl.deinit(); } From 982a1bea2ff33957a31a5927f163546bb57373e1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 10 Sep 2026 12:05:52 -0700 Subject: [PATCH 02/21] actually put entries into CorsStore --- src/network/CorsGate.zig | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index ba9310eb8..798ee6f4a 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -46,6 +46,7 @@ const ACCESS_CONTROL_ALLOW_ORIGIN = "access-control-allow-origin"; const ACCESS_CONTROL_ALLOW_METHODS = "access-control-allow-methods"; const ACCESS_CONTROL_ALLOW_HEADERS = "access-control-allow-headers"; const ACCESS_CONTROL_ALLOW_CREDENTIALS = "access-control-allow-credentials"; +const ACCESS_CONTROL_MAX_AGE = "access-control-max-age"; pub fn deinit(self: *CorsGate) void { self.single_flight.deinit(); @@ -294,6 +295,7 @@ const CorsPreflightContext = struct { key: []const u8, url: [:0]const u8, origin: []const u8, + target: []const u8, method: http.Method, request_headers: []const []const u8, wants_credentials: bool, @@ -390,6 +392,52 @@ const CorsPreflightContext = struct { return true; } + fn cacheGrant(self: *CorsPreflightContext, acam: ?[]const u8, acah: ?[]const u8, acma: ?[]const u8) !void { + if (self.target.len == 0) return; + + const max_age_s: u64 = if (acma) |v| std.fmt.parseUnsigned(u64, v, 10) catch return else return; + if (max_age_s == 0) return; + + const capped_s: u64 = @min(max_age_s, 7200); + const capped_ms = capped_s * 1000; + + const allocator = self.arena.allocator(); + + const methods_wildcard = acam != null and std.mem.eql(u8, acam.?, "*") and !self.wants_credentials; + var methods = std.EnumSet(http.Method).initEmpty(); + if (!methods_wildcard) { + if (acam) |list| { + var it = std.mem.splitScalar(u8, list, ','); + while (it.next()) |raw| { + const token = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (std.meta.stringToEnum(http.Method, token)) |m| methods.insert(m); + } + } + } + + const headers_wildcard = acah != null and std.mem.eql(u8, acah.?, "*") and !self.wants_credentials; + var owned_headers: []const []const u8 = &.{}; + if (!headers_wildcard and self.request_headers.len > 0) { + const dup = try allocator.alloc([]const u8, self.request_headers.len); + for (self.request_headers, 0..) |src, i| { + dup[i] = try allocator.dupe(u8, src); + } + owned_headers = dup; + } + + try self.gate.network.cors_store.put( + .{ .origin = self.origin, .target = self.target }, + .{ + .credentials = self.wants_credentials, + .methods_wildcard = methods_wildcard, + .methods = methods, + .headers_wildcard = headers_wildcard, + .headers = owned_headers, + .expires_at = lp.datetime.timestamp(.real) + capped_ms, + }, + ); + } + fn methodAllowed(list: []const u8, method: http.Method) bool { const method_name = @tagName(method); var it = std.mem.splitScalar(u8, list, ','); @@ -428,6 +476,7 @@ const CorsPreflightContext = struct { var acam: ?[]const u8 = null; var acah: ?[]const u8 = null; var acac: ?[]const u8 = null; + var acma: ?[]const u8 = null; var iter = transfer.responseHeaderIterator(); while (iter.next()) |hdr| { @@ -439,10 +488,17 @@ const CorsPreflightContext = struct { acah = hdr.value; } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_CREDENTIALS, hdr.name)) { acac = hdr.value; + } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_MAX_AGE, hdr.name)) { + acma = hdr.value; } } self.allowed = self.validateHeaders(acao, acam, acah, acac); + if (self.allowed) { + self.cacheGrant(acam, acah, acma) catch |err| { + log.warn(.cors, "preflight cache store failed", .{ .url = self.url, .err = err }); + }; + } return .proceed; } @@ -520,6 +576,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { const owned_url = try arena.dupeZ(u8, transfer.req.url); const owned_key = try arena.dupe(u8, key); const owned_origin = try arena.dupe(u8, origin); + const owned_target = try URL.getOrigin(arena.allocator(), transfer.req.url) orelse ""; const referer: ?[]const u8 = transfer.findRequestHeader("referer"); @@ -536,6 +593,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { .key = owned_key, .url = owned_url, .origin = owned_origin, + .target = owned_target, .method = transfer.req.method, .request_headers = owned_header_names, .wants_credentials = transfer.req.credentials_mode == .include, From 9296dff6445bf77f6bee2e7575cc96ea9d3e9729 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 10 Sep 2026 12:19:36 -0700 Subject: [PATCH 03/21] fix more WPT test edge cases for preflight cache --- src/network/CorsGate.zig | 39 +++++++++++++++++++-------------------- src/network/CorsStore.zig | 14 +++++++------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 798ee6f4a..59685cb9d 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -217,19 +217,17 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .allowed; } - if (try URL.getOrigin(transfer.arena.allocator(), req.url)) |target| { - if (self.network.cors_store.get(.{ .origin = origin, .target = target })) |cached| { - const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); - if (CorsStore.covers(cached, req.method, req.credentials_mode == .include, authored.items)) { - log.debug(.cors, "cross origin", .{ - .url = req.url, - .origin = origin, - .preflight = false, - .cached = true, - }); - lp.metrics.cors_check.incr(.cached); - return .allowed; - } + if (self.network.cors_store.get(.{ .origin = origin, .target = req.url })) |cached| { + const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); + if (CorsStore.covers(cached, req.method, req.credentials_mode == .include, authored.items)) { + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = origin, + .preflight = false, + .cached = true, + }); + lp.metrics.cors_check.incr(.cached); + return .allowed; } } @@ -295,7 +293,6 @@ const CorsPreflightContext = struct { key: []const u8, url: [:0]const u8, origin: []const u8, - target: []const u8, method: http.Method, request_headers: []const []const u8, wants_credentials: bool, @@ -393,9 +390,13 @@ const CorsPreflightContext = struct { } fn cacheGrant(self: *CorsPreflightContext, acam: ?[]const u8, acah: ?[]const u8, acma: ?[]const u8) !void { - if (self.target.len == 0) return; + if (self.url.len == 0) return; - const max_age_s: u64 = if (acma) |v| std.fmt.parseUnsigned(u64, v, 10) catch return else return; + const max_age_s: u64 = blk: { + const v = acma orelse break :blk 5; + if (v.len == 0) break :blk 5; + break :blk std.fmt.parseUnsigned(u64, v, 10) catch return; + }; if (max_age_s == 0) return; const capped_s: u64 = @min(max_age_s, 7200); @@ -426,14 +427,14 @@ const CorsPreflightContext = struct { } try self.gate.network.cors_store.put( - .{ .origin = self.origin, .target = self.target }, + .{ .origin = self.origin, .target = self.url }, .{ .credentials = self.wants_credentials, .methods_wildcard = methods_wildcard, .methods = methods, .headers_wildcard = headers_wildcard, .headers = owned_headers, - .expires_at = lp.datetime.timestamp(.real) + capped_ms, + .expires_at = lp.datetime.milliTimestamp(.real) + capped_ms, }, ); } @@ -576,7 +577,6 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { const owned_url = try arena.dupeZ(u8, transfer.req.url); const owned_key = try arena.dupe(u8, key); const owned_origin = try arena.dupe(u8, origin); - const owned_target = try URL.getOrigin(arena.allocator(), transfer.req.url) orelse ""; const referer: ?[]const u8 = transfer.findRequestHeader("referer"); @@ -593,7 +593,6 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { .key = owned_key, .url = owned_url, .origin = owned_origin, - .target = owned_target, .method = transfer.req.method, .request_headers = owned_header_names, .wants_credentials = transfer.req.credentials_mode == .include, diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 696c5676a..482639c0d 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -159,7 +159,7 @@ pub fn get(self: *CorsStore, key: Key) ?Entry { const entry = self.map.get(key) orelse return null; - if (entry.expires_at <= lp.datetime.timestamp(.real)) { + if (entry.expires_at <= lp.datetime.milliTimestamp(.real)) { const kv = self.map.fetchRemove(key).?; kv.key.deinit(self.allocator); kv.value.deinit(self.allocator); @@ -250,7 +250,7 @@ test "CorsStore: put then get, miss on different origin/target" { .methods = std.EnumSet(http.Method).initOne(.POST), .headers_wildcard = false, .headers = headers, - .expires_at = lp.datetime.timestamp(.real) + 60_000, + .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, }); // store.get returns the store's own copy — not caller-owned, don't free it. @@ -273,7 +273,7 @@ test "CorsStore: expired entries are evicted on get" { .methods = .initEmpty(), .headers_wildcard = true, .headers = &.{}, // empty slice, nothing to free - .expires_at = lp.datetime.timestamp(.real) - 1, + .expires_at = lp.datetime.milliTimestamp(.real) - 1, }); try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example" })); @@ -295,7 +295,7 @@ test "CorsStore: put merges into existing entry rather than clobbering" { .methods = std.EnumSet(http.Method).initOne(.POST), .headers_wildcard = false, .headers = h1, - .expires_at = lp.datetime.timestamp(.real) + 60_000, + .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, }); freeHeaders(allocator, h1); @@ -307,7 +307,7 @@ test "CorsStore: put merges into existing entry rather than clobbering" { .methods = std.EnumSet(http.Method).initOne(.PUT), .headers_wildcard = false, .headers = h2, - .expires_at = lp.datetime.timestamp(.real) + 60_000, + .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, }); freeHeaders(allocator, h2); @@ -328,7 +328,7 @@ test "CorsStore: covers rejects credentialed request against uncredentialed wild .methods = .initEmpty(), .headers_wildcard = true, .headers = &.{}, - .expires_at = std.math.maxInt(i64), + .expires_at = std.math.maxInt(u64), }; try testing.expect(!CorsStore.covers(entry, .GET, true, &.{})); try testing.expect(CorsStore.covers(entry, .GET, false, &.{})); @@ -341,7 +341,7 @@ test "CorsStore: covers never lets a wildcard cover Authorization" { .methods = .initEmpty(), .headers_wildcard = true, .headers = &.{}, - .expires_at = std.math.maxInt(i64), + .expires_at = std.math.maxInt(u64), }; try testing.expect(!CorsStore.covers(entry, .GET, false, &.{"authorization"})); try testing.expect(CorsStore.covers(entry, .GET, false, &.{"x-anything"})); From 74d13e4193af9dfcd1d5e32add7038df303bd4e1 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 13 Sep 2026 14:58:11 -0700 Subject: [PATCH 04/21] make key hash eql case sensitive --- src/network/CorsStore.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 482639c0d..6ea90bb0e 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -50,7 +50,7 @@ const KeyContext = struct { } pub fn eql(_: KeyContext, a: Key, b: Key) bool { - return std.ascii.eqlIgnoreCase(a.origin, b.origin) and std.ascii.eqlIgnoreCase(a.target, b.target); + return std.mem.eql(u8, a.origin, b.origin) and std.mem.eql(u8, a.target, b.target); } }; From 777b954dad79a3921ad4f484252be8bd3075233b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 13 Sep 2026 15:08:36 -0700 Subject: [PATCH 05/21] add credentials to cors gate key --- src/network/CorsGate.zig | 15 ++++-- src/network/CorsStore.zig | 104 +++++++++++++++++++++++++------------- 2 files changed, 78 insertions(+), 41 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 59685cb9d..7b390add8 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -77,7 +77,7 @@ fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { } } -fn isSafelistedMethod(value: http.Method) bool { +pub fn isSafelistedMethod(value: http.Method) bool { return switch (value) { .GET, .HEAD, .POST => true, else => false, @@ -217,9 +217,15 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .allowed; } - if (self.network.cors_store.get(.{ .origin = origin, .target = req.url })) |cached| { + const wants_credentials = req.credentials_mode == .include; + + if (self.network.cors_store.get(.{ + .origin = origin, + .target = req.url, + .credentials = wants_credentials, + })) |cached| { const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); - if (CorsStore.covers(cached, req.method, req.credentials_mode == .include, authored.items)) { + if (CorsStore.covers(cached, req.method, authored.items)) { log.debug(.cors, "cross origin", .{ .url = req.url, .origin = origin, @@ -427,9 +433,8 @@ const CorsPreflightContext = struct { } try self.gate.network.cors_store.put( - .{ .origin = self.origin, .target = self.url }, + .{ .origin = self.origin, .target = self.url, .credentials = self.wants_credentials }, .{ - .credentials = self.wants_credentials, .methods_wildcard = methods_wildcard, .methods = methods, .headers_wildcard = headers_wildcard, diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 6ea90bb0e..2333b6a87 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -20,17 +20,20 @@ const std = @import("std"); const lp = @import("lightpanda"); const http = @import("http.zig"); +const isSafelistedMethod = @import("CorsGate.zig").isSafelistedMethod; const CorsStore = @This(); const Key = struct { origin: []const u8, target: []const u8, + credentials: bool, fn dupe(self: Key, allocator: std.mem.Allocator) !Key { return .{ .origin = try allocator.dupe(u8, self.origin), .target = try allocator.dupe(u8, self.target), + .credentials = self.credentials, }; } @@ -46,11 +49,15 @@ const KeyContext = struct { hasher.update(key.origin); hasher.update(&.{0}); hasher.update(key.target); + hasher.update(&.{0}); + hasher.update(&.{@intFromBool(key.credentials)}); return hasher.final(); } pub fn eql(_: KeyContext, a: Key, b: Key) bool { - return std.mem.eql(u8, a.origin, b.origin) and std.mem.eql(u8, a.target, b.target); + return std.mem.eql(u8, a.origin, b.origin) and + std.mem.eql(u8, a.target, b.target) and + a.credentials == b.credentials; } }; @@ -63,8 +70,6 @@ const Entry = struct { expires_at: u64, - credentials: bool, - fn unionHeaders( allocator: std.mem.Allocator, a: []const []const u8, @@ -94,7 +99,6 @@ const Entry = struct { fn merge(self: Entry, allocator: std.mem.Allocator, new: Entry) !Entry { return .{ - .credentials = self.credentials or new.credentials, .methods_wildcard = self.methods_wildcard or new.methods_wildcard, .methods = self.methods.unionWith(new.methods), .headers_wildcard = self.headers_wildcard or new.headers_wildcard, @@ -120,7 +124,6 @@ const Entry = struct { .headers_wildcard = self.headers_wildcard, .headers = new_headers.items, .expires_at = self.expires_at, - .credentials = self.credentials, }; } @@ -200,14 +203,9 @@ pub fn put(self: *CorsStore, key: Key, entry: Entry) !void { pub fn covers( entry: Entry, method: http.Method, - wants_credentials: bool, authored_headers: []const []const u8, ) bool { - if (wants_credentials and !entry.credentials) { - return false; - } - - if (!entry.methods_wildcard and !entry.methods.contains(method)) { + if (!isSafelistedMethod(method) and !entry.methods_wildcard and !entry.methods.contains(method)) { return false; } @@ -235,7 +233,7 @@ fn freeHeaders(allocator: std.mem.Allocator, headers: []const []const u8) void { allocator.free(headers); } -test "CorsStore: put then get, miss on different origin/target" { +test "CorsStore: put then get, miss on different origin/target/credentials" { const allocator = testing.allocator; var store = CorsStore.init(allocator); defer store.deinit(); @@ -244,8 +242,7 @@ test "CorsStore: put then get, miss on different origin/target" { headers[0] = try allocator.dupe(u8, "x-custom"); defer freeHeaders(allocator, headers); - try store.put(.{ .origin = "https://a.example", .target = "https://api.example" }, .{ - .credentials = false, + try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{ .methods_wildcard = false, .methods = std.EnumSet(http.Method).initOne(.POST), .headers_wildcard = false, @@ -254,12 +251,15 @@ test "CorsStore: put then get, miss on different origin/target" { }); // store.get returns the store's own copy — not caller-owned, don't free it. - const hit = store.get(.{ .origin = "https://a.example", .target = "https://api.example" }).?; + const hit = store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }).?; try testing.expect(hit.methods.contains(.POST)); try testing.expect(!hit.methods.contains(.GET)); - try testing.expectEqual(null, store.get(.{ .origin = "https://b.example", .target = "https://api.example" })); - try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://other.example" })); + try testing.expectEqual(null, store.get(.{ .origin = "https://b.example", .target = "https://api.example", .credentials = false })); + try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://other.example", .credentials = false })); + + // Same origin/target but different credentials mode: separate entry, must miss. + try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true })); } test "CorsStore: expired entries are evicted on get" { @@ -267,8 +267,7 @@ test "CorsStore: expired entries are evicted on get" { var store = CorsStore.init(allocator); defer store.deinit(); - try store.put(.{ .origin = "https://a.example", .target = "https://api.example" }, .{ - .credentials = false, + try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{ .methods_wildcard = true, .methods = .initEmpty(), .headers_wildcard = true, @@ -276,7 +275,7 @@ test "CorsStore: expired entries are evicted on get" { .expires_at = lp.datetime.milliTimestamp(.real) - 1, }); - try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example" })); + try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })); try testing.expectEqual(0, store.map.count()); } @@ -285,12 +284,11 @@ test "CorsStore: put merges into existing entry rather than clobbering" { var store = CorsStore.init(allocator); defer store.deinit(); - const key = Key{ .origin = "https://a.example", .target = "https://api.example" }; + const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }; const h1 = try allocator.alloc([]const u8, 1); h1[0] = try allocator.dupe(u8, "x-one"); try store.put(key, .{ - .credentials = false, .methods_wildcard = false, .methods = std.EnumSet(http.Method).initOne(.POST), .headers_wildcard = false, @@ -302,7 +300,6 @@ test "CorsStore: put merges into existing entry rather than clobbering" { const h2 = try allocator.alloc([]const u8, 1); h2[0] = try allocator.dupe(u8, "x-two"); try store.put(key, .{ - .credentials = false, .methods_wildcard = false, .methods = std.EnumSet(http.Method).initOne(.PUT), .headers_wildcard = false, @@ -316,33 +313,68 @@ test "CorsStore: put merges into existing entry rather than clobbering" { try testing.expect(merged.methods.contains(.PUT)); try testing.expectEqual(2, merged.headers.len); - try testing.expect(CorsStore.covers(merged, .POST, false, &.{"x-one"})); - try testing.expect(CorsStore.covers(merged, .PUT, false, &.{"x-two"})); - try testing.expect(!CorsStore.covers(merged, .DELETE, false, &.{})); + try testing.expect(CorsStore.covers(merged, .POST, &.{"x-one"})); + try testing.expect(CorsStore.covers(merged, .PUT, &.{"x-two"})); + try testing.expect(!CorsStore.covers(merged, .DELETE, &.{})); } -test "CorsStore: covers rejects credentialed request against uncredentialed wildcard" { - const entry = CorsStore.Entry{ - .credentials = false, +test "CorsStore: credentialed and non-credentialed grants for same origin/target stay separate" { + const allocator = testing.allocator; + var store = CorsStore.init(allocator); + defer store.deinit(); + + const origin = "https://a.example"; + const target = "https://api.example"; + + // Non-credentialed grant: wildcard headers allowed (valid per spec for non-cred requests). + try store.put(.{ .origin = origin, .target = target, .credentials = false }, .{ .methods_wildcard = true, .methods = .initEmpty(), .headers_wildcard = true, .headers = &.{}, - .expires_at = std.math.maxInt(u64), - }; - try testing.expect(!CorsStore.covers(entry, .GET, true, &.{})); - try testing.expect(CorsStore.covers(entry, .GET, false, &.{})); + .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, + }); + + // Credentialed grant: explicit methods/headers only, no wildcard. + const h = try allocator.alloc([]const u8, 1); + h[0] = try allocator.dupe(u8, "x-custom"); + try store.put(.{ .origin = origin, .target = target, .credentials = true }, .{ + .methods_wildcard = false, + .methods = std.EnumSet(http.Method).initOne(.GET), + .headers_wildcard = false, + .headers = h, + .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, + }); + freeHeaders(allocator, h); + + const non_cred = store.get(.{ .origin = origin, .target = target, .credentials = false }).?; + const cred = store.get(.{ .origin = origin, .target = target, .credentials = true }).?; + + // The two entries must never have merged: the credentialed entry must NOT + // have inherited the non-credentialed entry's wildcard. + try testing.expect(non_cred.headers_wildcard); + try testing.expect(!cred.headers_wildcard); + try testing.expect(!cred.methods_wildcard); + try testing.expect(cred.methods.contains(.GET)); + try testing.expect(!cred.methods.contains(.POST)); + + // A credentialed request asking for an arbitrary header must be rejected + // against the credentialed entry, even though the non-cred entry has a wildcard. + try testing.expect(!CorsStore.covers(cred, .GET, &.{"x-anything"})); + try testing.expect(CorsStore.covers(cred, .GET, &.{"x-custom"})); + + // The non-credentialed entry's wildcard still works for non-cred requests. + try testing.expect(CorsStore.covers(non_cred, .GET, &.{"x-anything"})); } test "CorsStore: covers never lets a wildcard cover Authorization" { const entry = CorsStore.Entry{ - .credentials = false, .methods_wildcard = true, .methods = .initEmpty(), .headers_wildcard = true, .headers = &.{}, .expires_at = std.math.maxInt(u64), }; - try testing.expect(!CorsStore.covers(entry, .GET, false, &.{"authorization"})); - try testing.expect(CorsStore.covers(entry, .GET, false, &.{"x-anything"})); + try testing.expect(!CorsStore.covers(entry, .GET, &.{"authorization"})); + try testing.expect(CorsStore.covers(entry, .GET, &.{"x-anything"})); } From 744dac3a13b71cc4c3f0c17de6aa65e01bfb7f5b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 13 Sep 2026 15:59:33 -0700 Subject: [PATCH 06/21] CorsStore.get returns a owned Entry --- src/network/CorsGate.zig | 3 ++- src/network/CorsStore.zig | 30 +++++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 7b390add8..b8a3d4fdc 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -219,11 +219,12 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const wants_credentials = req.credentials_mode == .include; - if (self.network.cors_store.get(.{ + if (try self.network.cors_store.get(.{ .origin = origin, .target = req.url, .credentials = wants_credentials, })) |cached| { + defer cached.deinit(self.network.cors_store.allocator); const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); if (CorsStore.covers(cached, req.method, authored.items)) { log.debug(.cors, "cross origin", .{ diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 2333b6a87..f50dfe8c6 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -61,7 +61,7 @@ const KeyContext = struct { } }; -const Entry = struct { +pub const Entry = struct { methods_wildcard: bool, methods: std.EnumSet(http.Method), @@ -127,7 +127,7 @@ const Entry = struct { }; } - fn deinit(self: Entry, allocator: std.mem.Allocator) void { + pub fn deinit(self: Entry, allocator: std.mem.Allocator) void { for (self.headers) |h| allocator.free(h); allocator.free(self.headers); } @@ -156,7 +156,7 @@ pub fn deinit(self: *CorsStore) void { self.map.deinit(self.allocator); } -pub fn get(self: *CorsStore, key: Key) ?Entry { +pub fn get(self: *CorsStore, key: Key) !?Entry { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); @@ -169,7 +169,7 @@ pub fn get(self: *CorsStore, key: Key) ?Entry { return null; } - return entry; + return try entry.dupe(self.allocator); } /// Insert or merge a CORS grant for (origin, target). `entry` is not @@ -250,16 +250,17 @@ test "CorsStore: put then get, miss on different origin/target/credentials" { .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, }); - // store.get returns the store's own copy — not caller-owned, don't free it. - const hit = store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }).?; + // store.get returns a caller-owned copy: it must be freed. + const hit = (try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })).?; + defer hit.deinit(allocator); try testing.expect(hit.methods.contains(.POST)); try testing.expect(!hit.methods.contains(.GET)); - try testing.expectEqual(null, store.get(.{ .origin = "https://b.example", .target = "https://api.example", .credentials = false })); - try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://other.example", .credentials = false })); + try testing.expectEqual(null, try store.get(.{ .origin = "https://b.example", .target = "https://api.example", .credentials = false })); + try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://other.example", .credentials = false })); // Same origin/target but different credentials mode: separate entry, must miss. - try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true })); + try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true })); } test "CorsStore: expired entries are evicted on get" { @@ -275,7 +276,7 @@ test "CorsStore: expired entries are evicted on get" { .expires_at = lp.datetime.milliTimestamp(.real) - 1, }); - try testing.expectEqual(null, store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })); + try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })); try testing.expectEqual(0, store.map.count()); } @@ -308,7 +309,8 @@ test "CorsStore: put merges into existing entry rather than clobbering" { }); freeHeaders(allocator, h2); - const merged = store.get(key).?; + const merged = (try store.get(key)).?; + defer merged.deinit(allocator); try testing.expect(merged.methods.contains(.POST)); try testing.expect(merged.methods.contains(.PUT)); try testing.expectEqual(2, merged.headers.len); @@ -347,8 +349,10 @@ test "CorsStore: credentialed and non-credentialed grants for same origin/target }); freeHeaders(allocator, h); - const non_cred = store.get(.{ .origin = origin, .target = target, .credentials = false }).?; - const cred = store.get(.{ .origin = origin, .target = target, .credentials = true }).?; + const non_cred = (try store.get(.{ .origin = origin, .target = target, .credentials = false })).?; + defer non_cred.deinit(allocator); + const cred = (try store.get(.{ .origin = origin, .target = target, .credentials = true })).?; + defer cred.deinit(allocator); // The two entries must never have merged: the credentialed entry must NOT // have inherited the non-credentialed entry's wildcard. From 4afdc412eec21f03d24bc3e55a236f75965cb833 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 07:33:14 -0700 Subject: [PATCH 07/21] add cors-store-entry-limit option --- src/Config.zig | 8 ++++++++ src/help.zon | 3 +++ 2 files changed, 11 insertions(+) diff --git a/src/Config.zig b/src/Config.zig index 3562346c6..9dd641380 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -254,6 +254,7 @@ pub const ExperimentalFeatures = packed struct(u2) { const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, .{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000 }, + .{ .name = "cors_store_entry_limit", .type = ?u32, .default = 1000 }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, @@ -570,6 +571,13 @@ pub fn robotStoreEntryLimit(self: *const Config) u32 { }; } +pub fn corsStoreEntryLimit(self: *const Config) u32 { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.cors_store_entry_limit.?, + else => 1000, + }; +} + pub fn httpVersion(self: *const Config) HttpVersion { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.http_version, diff --git a/src/help.zon b/src/help.zon index 4fcf2426d..65b9d323c 100644 --- a/src/help.zon +++ b/src/help.zon @@ -505,6 +505,9 @@ \\ --robot-store-entry-limit \\ Maximum number of entries kept in the RobotStore. 0 means no limit. \\ Defaults to 1000. + \\ --cors-store-entry-limit + \\ Maximum number of entries kept in the CorsStore. 0 means no limit. + \\ Defaults to 1000. \\ --proxy-bearer-token \\ Token sent for bearer authentication with the proxy: \\ Proxy-Authorization: Bearer . From cc42b9f9c390cf73148e388e93c9406198f59bd2 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 07:39:57 -0700 Subject: [PATCH 08/21] use ClockCache in CorsStore --- src/network/ClockCache.zig | 9 ++++ src/network/CorsStore.zig | 102 +++++++++++++++++-------------------- src/network/Network.zig | 2 +- 3 files changed, 56 insertions(+), 57 deletions(-) diff --git a/src/network/ClockCache.zig b/src/network/ClockCache.zig index 4eaa21372..4a6b2ad53 100644 --- a/src/network/ClockCache.zig +++ b/src/network/ClockCache.zig @@ -64,6 +64,15 @@ pub fn ClockCache(comptime V: type) type { return &entry.value; } + pub fn remove(self: *Self, key: []const u8) ?V { + const index = self.map.getIndex(key) orelse return null; + const owned_key = self.map.keys()[index]; + const value = self.map.values()[index].value; + self.map.swapRemoveAt(index); + self.allocator.free(owned_key); + return value; + } + pub fn insert(self: *Self, key: []const u8, value: V) !InsertResult { const gop = try self.map.getOrPut(self.allocator, key); if (gop.found_existing) return .exists; diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index f50dfe8c6..7a5f12efa 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -21,43 +21,27 @@ const lp = @import("lightpanda"); const http = @import("http.zig"); const isSafelistedMethod = @import("CorsGate.zig").isSafelistedMethod; +const ClockCache = @import("ClockCache.zig").ClockCache; const CorsStore = @This(); -const Key = struct { +pub const Key = struct { origin: []const u8, target: []const u8, credentials: bool, - fn dupe(self: Key, allocator: std.mem.Allocator) !Key { - return .{ - .origin = try allocator.dupe(u8, self.origin), - .target = try allocator.dupe(u8, self.target), - .credentials = self.credentials, - }; - } + /// Serializes into a single string suitable as a ClockCache key. + fn build(self: Key, allocator: std.mem.Allocator) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); - fn deinit(self: Key, allocator: std.mem.Allocator) void { - allocator.free(self.origin); - allocator.free(self.target); - } -}; + try buf.appendSlice(allocator, self.origin); + try buf.append(allocator, 0); + try buf.appendSlice(allocator, self.target); + try buf.append(allocator, 0); + try buf.append(allocator, @intFromBool(self.credentials)); -const KeyContext = struct { - pub fn hash(_: KeyContext, key: Key) u64 { - var hasher = std.hash.Wyhash.init(0); - hasher.update(key.origin); - hasher.update(&.{0}); - hasher.update(key.target); - hasher.update(&.{0}); - hasher.update(&.{@intFromBool(key.credentials)}); - return hasher.final(); - } - - pub fn eql(_: KeyContext, a: Key, b: Key) bool { - return std.mem.eql(u8, a.origin, b.origin) and - std.mem.eql(u8, a.target, b.target) and - a.credentials == b.credentials; + return buf.toOwnedSlice(allocator); } }; @@ -133,39 +117,38 @@ pub const Entry = struct { } }; -const Map = std.HashMapUnmanaged(Key, Entry, KeyContext, std.hash_map.default_max_load_percentage); - allocator: std.mem.Allocator, -map: Map = .empty, +map: ClockCache(Entry), mutex: std.Io.Mutex = .init, -pub fn init(allocator: std.mem.Allocator) CorsStore { - return .{ .allocator = allocator }; +pub fn init(allocator: std.mem.Allocator, capacity: usize) CorsStore { + return .{ .allocator = allocator, .map = .init(allocator, capacity) }; } pub fn deinit(self: *CorsStore) void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - var iter = self.map.iterator(); - while (iter.next()) |entry| { - entry.key_ptr.deinit(self.allocator); - entry.value_ptr.deinit(self.allocator); + for (self.map.entries()) |*entry| { + entry.value.deinit(self.allocator); } - - self.map.deinit(self.allocator); + self.map.deinit(); } pub fn get(self: *CorsStore, key: Key) !?Entry { + const cache_key = try key.build(self.allocator); + defer self.allocator.free(cache_key); + self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - const entry = self.map.get(key) orelse return null; + const entry = self.map.get(cache_key) orelse return null; if (entry.expires_at <= lp.datetime.milliTimestamp(.real)) { - const kv = self.map.fetchRemove(key).?; - kv.key.deinit(self.allocator); - kv.value.deinit(self.allocator); + if (self.map.remove(cache_key)) |e| { + e.deinit(self.allocator); + } + return null; } @@ -181,23 +164,31 @@ pub fn get(self: *CorsStore, key: Key) !?Entry { /// freeing `entry.headers` after this call, on both the insert and /// the merge path. pub fn put(self: *CorsStore, key: Key, entry: Entry) !void { + const cache_key = try key.build(self.allocator); + defer self.allocator.free(cache_key); + self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - const gop = try self.map.getOrPut(self.allocator, key); - if (!gop.found_existing) { - errdefer _ = self.map.remove(key); - gop.key_ptr.* = try key.dupe(self.allocator); - gop.value_ptr.* = try entry.dupe(self.allocator); + if (self.map.get(cache_key)) |existing| { + const merged = try existing.merge(self.allocator, entry); + existing.deinit(self.allocator); + existing.* = merged; return; } - const old = gop.value_ptr.*; - const merged = old.merge(self.allocator, entry) catch |err| { - return err; - }; - old.deinit(self.allocator); - gop.value_ptr.* = merged; + const owned_entry = try entry.dupe(self.allocator); + errdefer owned_entry.deinit(self.allocator); + + switch (try self.map.insert(cache_key, owned_entry)) { + .exists => unreachable, + .inserted => |evicted| { + if (evicted) |v| { + var e = v; + e.deinit(self.allocator); + } + }, + } } pub fn covers( @@ -263,7 +254,7 @@ test "CorsStore: put then get, miss on different origin/target/credentials" { try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true })); } -test "CorsStore: expired entries are evicted on get" { +test "CorsStore: expired entries are treated as a miss on get" { const allocator = testing.allocator; var store = CorsStore.init(allocator); defer store.deinit(); @@ -277,7 +268,6 @@ test "CorsStore: expired entries are evicted on get" { }); try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })); - try testing.expectEqual(0, store.map.count()); } test "CorsStore: put merges into existing entry rather than clobbering" { diff --git a/src/network/Network.zig b/src/network/Network.zig index 7a78d5a24..b27dc4329 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -124,7 +124,7 @@ pub fn init(app: *App) !Network { .cache = cache, .robot_store = RobotStore.init(allocator, config.robotStoreEntryLimit()), - .cors_store = CorsStore.init(allocator), + .cors_store = CorsStore.init(allocator, config.corsStoreEntryLimit()), .web_bot_auth = web_bot_auth, .rate_limiter = if (config.httpNavDelay()) |ms| RateLimiter.init(allocator, ms, config.httpNavBurst()) else null, .adblocker = adblocker, From b74d11e803b4ead8b84eb030dd03d0f75c85afef Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 07:44:54 -0700 Subject: [PATCH 09/21] check expiration whenever we get entry in CorsStore --- src/network/CorsStore.zig | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 7a5f12efa..ae830ce94 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -135,13 +135,8 @@ pub fn deinit(self: *CorsStore) void { self.map.deinit(); } -pub fn get(self: *CorsStore, key: Key) !?Entry { - const cache_key = try key.build(self.allocator); - defer self.allocator.free(cache_key); - - self.mutex.lockUncancelable(lp.io); - defer self.mutex.unlock(lp.io); - +// Caller is expected to be holding mutex. +fn getWithExpiration(self: *CorsStore, cache_key: []const u8) ?*Entry { const entry = self.map.get(cache_key) orelse return null; if (entry.expires_at <= lp.datetime.milliTimestamp(.real)) { @@ -152,6 +147,17 @@ pub fn get(self: *CorsStore, key: Key) !?Entry { return null; } + return entry; +} + +pub fn get(self: *CorsStore, key: Key) !?Entry { + const cache_key = try key.build(self.allocator); + defer self.allocator.free(cache_key); + + self.mutex.lockUncancelable(lp.io); + defer self.mutex.unlock(lp.io); + + const entry = self.getWithExpiration(cache_key) orelse return null; return try entry.dupe(self.allocator); } @@ -170,7 +176,7 @@ pub fn put(self: *CorsStore, key: Key, entry: Entry) !void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - if (self.map.get(cache_key)) |existing| { + if (self.getWithExpiration(cache_key)) |existing| { const merged = try existing.merge(self.allocator, entry); existing.deinit(self.allocator); existing.* = merged; From c62d92a16996e111d13b020aa47703e61665e6cb Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 07:50:42 -0700 Subject: [PATCH 10/21] use effectiveOrigin in CorsGate --- src/network/CorsGate.zig | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index b8a3d4fdc..dfd2dcfd2 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -541,7 +541,7 @@ const CorsPreflightContext = struct { fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { const url = transfer.req.url; - const origin = transfer.req.origin orelse "null"; + const origin = transfer.effectiveOrigin(); var header_names: std.ArrayList([]const u8) = .empty; for (transfer.req_headers.items) |hdr| { @@ -625,11 +625,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { errdefer fetch_transfer.deinit(); // Origin - try fetch_transfer.setHeader( - ORIGIN, - transfer.req.origin orelse "null", - .{}, - ); + try fetch_transfer.setHeader(ORIGIN, transfer.effectiveOrigin(), .{}); if (referer) |r| { try fetch_transfer.setHeader("Referer", r, .{}); From 24ef506c918053cfd3a1264d86f7182c009c7548 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 07:56:33 -0700 Subject: [PATCH 11/21] merge get and covers to prevent entry duplication in CorsStore --- src/network/CorsGate.zig | 25 ++++++++--------- src/network/CorsStore.zig | 58 +++++++++++++++++++-------------------- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index dfd2dcfd2..ee01c975d 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -219,23 +219,20 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const wants_credentials = req.credentials_mode == .include; - if (try self.network.cors_store.get(.{ + const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); + if (try self.network.cors_store.covers(.{ .origin = origin, .target = req.url, .credentials = wants_credentials, - })) |cached| { - defer cached.deinit(self.network.cors_store.allocator); - const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); - if (CorsStore.covers(cached, req.method, authored.items)) { - log.debug(.cors, "cross origin", .{ - .url = req.url, - .origin = origin, - .preflight = false, - .cached = true, - }); - lp.metrics.cors_check.incr(.cached); - return .allowed; - } + }, req.method, authored.items)) { + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = origin, + .preflight = false, + .cached = true, + }); + lp.metrics.cors_check.incr(.cached); + return .allowed; } log.debug(.cors, "cross origin", .{ diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index ae830ce94..82ec7f483 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -150,15 +150,41 @@ fn getWithExpiration(self: *CorsStore, cache_key: []const u8) ?*Entry { return entry; } -pub fn get(self: *CorsStore, key: Key) !?Entry { +fn matches(entry: Entry, method: http.Method, authored_headers: []const []const u8) bool { + if (!isSafelistedMethod(method) and !entry.methods_wildcard and !entry.methods.contains(method)) { + return false; + } + for (authored_headers) |name| { + const is_authorization = std.ascii.eqlIgnoreCase(name, "authorization"); + if (entry.headers_wildcard and !is_authorization) continue; + var found = false; + for (entry.headers) |allowed| { + if (std.ascii.eqlIgnoreCase(allowed, name)) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +/// Whether a cached grant for `key` covers this method/headers combination. +/// A miss (expired or absent entry) is treated as not covered. +pub fn covers( + self: *CorsStore, + key: Key, + method: http.Method, + authored_headers: []const []const u8, +) !bool { const cache_key = try key.build(self.allocator); defer self.allocator.free(cache_key); self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); - const entry = self.getWithExpiration(cache_key) orelse return null; - return try entry.dupe(self.allocator); + const entry = self.getWithExpiration(cache_key) orelse return false; + return matches(entry.*, method, authored_headers); } /// Insert or merge a CORS grant for (origin, target). `entry` is not @@ -197,32 +223,6 @@ pub fn put(self: *CorsStore, key: Key, entry: Entry) !void { } } -pub fn covers( - entry: Entry, - method: http.Method, - authored_headers: []const []const u8, -) bool { - if (!isSafelistedMethod(method) and !entry.methods_wildcard and !entry.methods.contains(method)) { - return false; - } - - for (authored_headers) |name| { - const is_authorization = std.ascii.eqlIgnoreCase(name, "authorization"); - if (entry.headers_wildcard and !is_authorization) continue; - - var found = false; - for (entry.headers) |allowed| { - if (std.ascii.eqlIgnoreCase(allowed, name)) { - found = true; - break; - } - } - if (!found) return false; - } - - return true; -} - const testing = @import("../testing.zig"); fn freeHeaders(allocator: std.mem.Allocator, headers: []const []const u8) void { From d6cbea9b1a43f71f27d23fc2c1e9ddd3de6f934e Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 07:58:48 -0700 Subject: [PATCH 12/21] use min expiration in CorsStore merge --- src/network/CorsStore.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 82ec7f483..1cdd32901 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -87,7 +87,7 @@ pub const Entry = struct { .methods = self.methods.unionWith(new.methods), .headers_wildcard = self.headers_wildcard or new.headers_wildcard, .headers = try unionHeaders(allocator, self.headers, new.headers), - .expires_at = @max(self.expires_at, new.expires_at), + .expires_at = @min(self.expires_at, new.expires_at), }; } From 1ae01f3fc2bea9dda78f29fad6ee3ab0ea140e38 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 08:00:45 -0700 Subject: [PATCH 13/21] check creds grant if non-creds is missing in CorsGate --- src/network/CorsGate.zig | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index ee01c975d..8978edf96 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -220,11 +220,21 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const wants_credentials = req.credentials_mode == .include; const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); - if (try self.network.cors_store.covers(.{ + + var covered = try self.network.cors_store.covers(.{ .origin = origin, .target = req.url, .credentials = wants_credentials, - }, req.method, authored.items)) { + }, req.method, authored.items); + if (!covered and !wants_credentials) { + covered = try self.network.cors_store.covers(.{ + .origin = origin, + .target = req.url, + .credentials = true, + }, req.method, authored.items); + } + + if (covered) { log.debug(.cors, "cross origin", .{ .url = req.url, .origin = origin, From ecd05ec878baf06406e9e9afff06dc8c6d768a09 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 08:06:19 -0700 Subject: [PATCH 14/21] fix CorsStore tests --- src/network/CorsStore.zig | 109 +++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 1cdd32901..f3688a4a1 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -230,9 +230,9 @@ fn freeHeaders(allocator: std.mem.Allocator, headers: []const []const u8) void { allocator.free(headers); } -test "CorsStore: put then get, miss on different origin/target/credentials" { +test "CorsStore: put then covers, miss on different origin/target/credentials" { const allocator = testing.allocator; - var store = CorsStore.init(allocator); + var store = CorsStore.init(allocator, 10); defer store.deinit(); const headers = try allocator.alloc([]const u8, 1); @@ -247,22 +247,39 @@ test "CorsStore: put then get, miss on different origin/target/credentials" { .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, }); - // store.get returns a caller-owned copy: it must be freed. - const hit = (try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })).?; - defer hit.deinit(allocator); - try testing.expect(hit.methods.contains(.POST)); - try testing.expect(!hit.methods.contains(.GET)); + try testing.expect(try store.covers( + .{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, + .POST, + &.{}, + )); + try testing.expect(!try store.covers( + .{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, + .PUT, + &.{}, + )); - try testing.expectEqual(null, try store.get(.{ .origin = "https://b.example", .target = "https://api.example", .credentials = false })); - try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://other.example", .credentials = false })); + try testing.expect(!try store.covers( + .{ .origin = "https://b.example", .target = "https://api.example", .credentials = false }, + .POST, + &.{}, + )); + try testing.expect(!try store.covers( + .{ .origin = "https://a.example", .target = "https://other.example", .credentials = false }, + .POST, + &.{}, + )); // Same origin/target but different credentials mode: separate entry, must miss. - try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = true })); + try testing.expect(!try store.covers( + .{ .origin = "https://a.example", .target = "https://api.example", .credentials = true }, + .POST, + &.{}, + )); } -test "CorsStore: expired entries are treated as a miss on get" { +test "CorsStore: expired entries are treated as a miss on covers" { const allocator = testing.allocator; - var store = CorsStore.init(allocator); + var store = CorsStore.init(allocator, 10); defer store.deinit(); try store.put(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .{ @@ -273,12 +290,16 @@ test "CorsStore: expired entries are treated as a miss on get" { .expires_at = lp.datetime.milliTimestamp(.real) - 1, }); - try testing.expectEqual(null, try store.get(.{ .origin = "https://a.example", .target = "https://api.example", .credentials = false })); + try testing.expect(!try store.covers( + .{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, + .GET, + &.{}, + )); } test "CorsStore: put merges into existing entry rather than clobbering" { const allocator = testing.allocator; - var store = CorsStore.init(allocator); + var store = CorsStore.init(allocator, 10); defer store.deinit(); const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }; @@ -305,20 +326,14 @@ test "CorsStore: put merges into existing entry rather than clobbering" { }); freeHeaders(allocator, h2); - const merged = (try store.get(key)).?; - defer merged.deinit(allocator); - try testing.expect(merged.methods.contains(.POST)); - try testing.expect(merged.methods.contains(.PUT)); - try testing.expectEqual(2, merged.headers.len); - - try testing.expect(CorsStore.covers(merged, .POST, &.{"x-one"})); - try testing.expect(CorsStore.covers(merged, .PUT, &.{"x-two"})); - try testing.expect(!CorsStore.covers(merged, .DELETE, &.{})); + try testing.expect(try store.covers(key, .POST, &.{"x-one"})); + try testing.expect(try store.covers(key, .PUT, &.{"x-two"})); + try testing.expect(!try store.covers(key, .DELETE, &.{})); } test "CorsStore: credentialed and non-credentialed grants for same origin/target stay separate" { const allocator = testing.allocator; - var store = CorsStore.init(allocator); + var store = CorsStore.init(allocator, 10); defer store.deinit(); const origin = "https://a.example"; @@ -345,36 +360,42 @@ test "CorsStore: credentialed and non-credentialed grants for same origin/target }); freeHeaders(allocator, h); - const non_cred = (try store.get(.{ .origin = origin, .target = target, .credentials = false })).?; - defer non_cred.deinit(allocator); - const cred = (try store.get(.{ .origin = origin, .target = target, .credentials = true })).?; - defer cred.deinit(allocator); - - // The two entries must never have merged: the credentialed entry must NOT - // have inherited the non-credentialed entry's wildcard. - try testing.expect(non_cred.headers_wildcard); - try testing.expect(!cred.headers_wildcard); - try testing.expect(!cred.methods_wildcard); - try testing.expect(cred.methods.contains(.GET)); - try testing.expect(!cred.methods.contains(.POST)); - // A credentialed request asking for an arbitrary header must be rejected // against the credentialed entry, even though the non-cred entry has a wildcard. - try testing.expect(!CorsStore.covers(cred, .GET, &.{"x-anything"})); - try testing.expect(CorsStore.covers(cred, .GET, &.{"x-custom"})); + try testing.expect(!try store.covers( + .{ .origin = origin, .target = target, .credentials = true }, + .GET, + &.{"x-anything"}, + )); + try testing.expect(try store.covers( + .{ .origin = origin, .target = target, .credentials = true }, + .GET, + &.{"x-custom"}, + )); + try testing.expect(!try store.covers( + .{ .origin = origin, .target = target, .credentials = true }, + .PUT, + &.{}, + )); // The non-credentialed entry's wildcard still works for non-cred requests. - try testing.expect(CorsStore.covers(non_cred, .GET, &.{"x-anything"})); + try testing.expect(try store.covers(.{ .origin = origin, .target = target, .credentials = false }, .GET, &.{"x-anything"})); } test "CorsStore: covers never lets a wildcard cover Authorization" { - const entry = CorsStore.Entry{ + const allocator = testing.allocator; + var store = CorsStore.init(allocator, 10); + defer store.deinit(); + + const key = Key{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }; + try store.put(key, .{ .methods_wildcard = true, .methods = .initEmpty(), .headers_wildcard = true, .headers = &.{}, .expires_at = std.math.maxInt(u64), - }; - try testing.expect(!CorsStore.covers(entry, .GET, &.{"authorization"})); - try testing.expect(CorsStore.covers(entry, .GET, &.{"x-anything"})); + }); + + try testing.expect(!try store.covers(key, .GET, &.{"authorization"})); + try testing.expect(try store.covers(key, .GET, &.{"x-anything"})); } From fbf7b2f8c865c7499f7c5eb208702b939944f72b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 12:00:38 -0700 Subject: [PATCH 15/21] cache grant for Cors in doneCallback --- src/network/CorsGate.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 8978edf96..e7ccc5fd4 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -312,6 +312,9 @@ const CorsPreflightContext = struct { wants_credentials: bool, allowed: bool = false, + acam: ?[]const u8 = null, + acah: ?[]const u8 = null, + acma: ?[]const u8 = null, fn validateHeaders( self: *CorsPreflightContext, @@ -509,15 +512,20 @@ const CorsPreflightContext = struct { self.allowed = self.validateHeaders(acao, acam, acah, acac); if (self.allowed) { - self.cacheGrant(acam, acah, acma) catch |err| { - log.warn(.cors, "preflight cache store failed", .{ .url = self.url, .err = err }); - }; + self.acam = acam; + self.acah = acah; + self.acma = acma; } return .proceed; } fn doneCallback(ctx_ptr: *anyopaque) anyerror!void { const self: *CorsPreflightContext = @ptrCast(@alignCast(ctx_ptr)); + if (self.allowed) { + self.cacheGrant(self.acam, self.acah, self.acma) catch |err| { + log.warn(.cors, "preflight cache store failed", .{ .url = self.url, .err = err }); + }; + } self.resolve(self.allowed); } From 682e69041982bb3c0b86dd567c0d9d2e7858a3ca Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 12:05:07 -0700 Subject: [PATCH 16/21] remove useless dupe in CorsStore --- src/network/CorsGate.zig | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index e7ccc5fd4..de5e1e3ef 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -419,8 +419,6 @@ const CorsPreflightContext = struct { const capped_s: u64 = @min(max_age_s, 7200); const capped_ms = capped_s * 1000; - const allocator = self.arena.allocator(); - const methods_wildcard = acam != null and std.mem.eql(u8, acam.?, "*") and !self.wants_credentials; var methods = std.EnumSet(http.Method).initEmpty(); if (!methods_wildcard) { @@ -434,14 +432,6 @@ const CorsPreflightContext = struct { } const headers_wildcard = acah != null and std.mem.eql(u8, acah.?, "*") and !self.wants_credentials; - var owned_headers: []const []const u8 = &.{}; - if (!headers_wildcard and self.request_headers.len > 0) { - const dup = try allocator.alloc([]const u8, self.request_headers.len); - for (self.request_headers, 0..) |src, i| { - dup[i] = try allocator.dupe(u8, src); - } - owned_headers = dup; - } try self.gate.network.cors_store.put( .{ .origin = self.origin, .target = self.url, .credentials = self.wants_credentials }, @@ -449,7 +439,7 @@ const CorsPreflightContext = struct { .methods_wildcard = methods_wildcard, .methods = methods, .headers_wildcard = headers_wildcard, - .headers = owned_headers, + .headers = if (headers_wildcard) &.{} else self.request_headers, .expires_at = lp.datetime.milliTimestamp(.real) + capped_ms, }, ); From d9539fbbe1572a5e06f2d8acf216dfc44a8e139f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 23 Sep 2026 12:08:23 -0700 Subject: [PATCH 17/21] check both covers on Request under single mutex --- src/network/CorsGate.zig | 18 ++++++------------ src/network/CorsStore.zig | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index de5e1e3ef..5b904cdc9 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -221,18 +221,12 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const authored = try collectAuthoredHeaders(transfer, transfer.arena.allocator()); - var covered = try self.network.cors_store.covers(.{ - .origin = origin, - .target = req.url, - .credentials = wants_credentials, - }, req.method, authored.items); - if (!covered and !wants_credentials) { - covered = try self.network.cors_store.covers(.{ - .origin = origin, - .target = req.url, - .credentials = true, - }, req.method, authored.items); - } + const covered = try self.network.cors_store.coversRequest( + transfer.arena.allocator(), + .{ .origin = origin, .target = req.url, .credentials = wants_credentials }, + req.method, + authored.items, + ); if (covered) { log.debug(.cors, "cross origin", .{ diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index f3688a4a1..6df9c1018 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -187,6 +187,32 @@ pub fn covers( return matches(entry.*, method, authored_headers); } +pub fn coversRequest( + self: *CorsStore, + allocator: std.mem.Allocator, + key: Key, + method: http.Method, + authored_headers: []const []const u8, +) !bool { + const primary_key = try key.build(allocator); + defer allocator.free(primary_key); + + self.mutex.lockUncancelable(lp.io); + defer self.mutex.unlock(lp.io); + + if (self.getWithExpiration(primary_key)) |entry| { + if (matches(entry.*, method, authored_headers)) return true; + } + + if (key.credentials) return false; + + const cred_key = try (Key{ .origin = key.origin, .target = key.target, .credentials = true }).build(allocator); + defer allocator.free(cred_key); + + const entry = self.getWithExpiration(cred_key) orelse return false; + return matches(entry.*, method, authored_headers); +} + /// Insert or merge a CORS grant for (origin, target). `entry` is not /// consumed: `put` copies whatever it needs (via `dupe`/`merge`, which /// always allocate their own copies) and never takes ownership of From ee2b97d9f8b405143a61b98b0bcb6060cf4ebcb3 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 24 Sep 2026 12:24:54 -0700 Subject: [PATCH 18/21] fix ordering of store help entries --- src/help.zon | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/help.zon b/src/help.zon index 65b9d323c..f48bc9091 100644 --- a/src/help.zon +++ b/src/help.zon @@ -396,6 +396,9 @@ \\ --cookie-jar \\ Path to a JSON file to save cookies to on exit (write-only). \\ Defaults to no cookie saving. + \\ --cors-store-entry-limit + \\ Maximum number of entries kept in the CorsStore. 0 means no limit. + \\ Defaults to 1000. \\ --experimental-features \\ Enable an experimental, unstable feature. Can be passed multiple times. \\ Behavior may change or be removed without notice. @@ -502,15 +505,12 @@ \\ --obey-robots \\ Fetches and obeys robots.txt of the target page. \\ Defaults to false. - \\ --robot-store-entry-limit - \\ Maximum number of entries kept in the RobotStore. 0 means no limit. - \\ Defaults to 1000. - \\ --cors-store-entry-limit - \\ Maximum number of entries kept in the CorsStore. 0 means no limit. - \\ Defaults to 1000. \\ --proxy-bearer-token \\ Token sent for bearer authentication with the proxy: \\ Proxy-Authorization: Bearer . + \\ --robot-store-entry-limit + \\ Maximum number of entries kept in the RobotStore. 0 means no limit. + \\ Defaults to 1000. \\ --timezone \\ Time zone used by Date and Intl, e.g. Europe/Paris or UTC. \\ Defaults to the host time zone. From 4957d109d3b5dbc73ed13ef0105e5358eec550c3 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 24 Sep 2026 12:26:24 -0700 Subject: [PATCH 19/21] use coversRequest everywhere in CorsStore --- src/network/CorsStore.zig | 57 +++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/network/CorsStore.zig b/src/network/CorsStore.zig index 6df9c1018..6674542b1 100644 --- a/src/network/CorsStore.zig +++ b/src/network/CorsStore.zig @@ -169,24 +169,6 @@ fn matches(entry: Entry, method: http.Method, authored_headers: []const []const return true; } -/// Whether a cached grant for `key` covers this method/headers combination. -/// A miss (expired or absent entry) is treated as not covered. -pub fn covers( - self: *CorsStore, - key: Key, - method: http.Method, - authored_headers: []const []const u8, -) !bool { - const cache_key = try key.build(self.allocator); - defer self.allocator.free(cache_key); - - self.mutex.lockUncancelable(lp.io); - defer self.mutex.unlock(lp.io); - - const entry = self.getWithExpiration(cache_key) orelse return false; - return matches(entry.*, method, authored_headers); -} - pub fn coversRequest( self: *CorsStore, allocator: std.mem.Allocator, @@ -273,30 +255,35 @@ test "CorsStore: put then covers, miss on different origin/target/credentials" { .expires_at = lp.datetime.milliTimestamp(.real) + 60_000, }); - try testing.expect(try store.covers( + try testing.expect(try store.coversRequest( + allocator, .{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .POST, &.{}, )); - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .PUT, &.{}, )); - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = "https://b.example", .target = "https://api.example", .credentials = false }, .POST, &.{}, )); - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = "https://a.example", .target = "https://other.example", .credentials = false }, .POST, &.{}, )); // Same origin/target but different credentials mode: separate entry, must miss. - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = "https://a.example", .target = "https://api.example", .credentials = true }, .POST, &.{}, @@ -316,7 +303,8 @@ test "CorsStore: expired entries are treated as a miss on covers" { .expires_at = lp.datetime.milliTimestamp(.real) - 1, }); - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = "https://a.example", .target = "https://api.example", .credentials = false }, .GET, &.{}, @@ -352,9 +340,9 @@ test "CorsStore: put merges into existing entry rather than clobbering" { }); freeHeaders(allocator, h2); - try testing.expect(try store.covers(key, .POST, &.{"x-one"})); - try testing.expect(try store.covers(key, .PUT, &.{"x-two"})); - try testing.expect(!try store.covers(key, .DELETE, &.{})); + try testing.expect(try store.coversRequest(allocator, key, .POST, &.{"x-one"})); + try testing.expect(try store.coversRequest(allocator, key, .PUT, &.{"x-two"})); + try testing.expect(!try store.coversRequest(allocator, key, .DELETE, &.{})); } test "CorsStore: credentialed and non-credentialed grants for same origin/target stay separate" { @@ -388,24 +376,27 @@ test "CorsStore: credentialed and non-credentialed grants for same origin/target // A credentialed request asking for an arbitrary header must be rejected // against the credentialed entry, even though the non-cred entry has a wildcard. - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = origin, .target = target, .credentials = true }, .GET, &.{"x-anything"}, )); - try testing.expect(try store.covers( + try testing.expect(try store.coversRequest( + allocator, .{ .origin = origin, .target = target, .credentials = true }, .GET, &.{"x-custom"}, )); - try testing.expect(!try store.covers( + try testing.expect(!try store.coversRequest( + allocator, .{ .origin = origin, .target = target, .credentials = true }, .PUT, &.{}, )); // The non-credentialed entry's wildcard still works for non-cred requests. - try testing.expect(try store.covers(.{ .origin = origin, .target = target, .credentials = false }, .GET, &.{"x-anything"})); + try testing.expect(try store.coversRequest(allocator, .{ .origin = origin, .target = target, .credentials = false }, .GET, &.{"x-anything"})); } test "CorsStore: covers never lets a wildcard cover Authorization" { @@ -422,6 +413,6 @@ test "CorsStore: covers never lets a wildcard cover Authorization" { .expires_at = std.math.maxInt(u64), }); - try testing.expect(!try store.covers(key, .GET, &.{"authorization"})); - try testing.expect(try store.covers(key, .GET, &.{"x-anything"})); + try testing.expect(!try store.coversRequest(allocator, key, .GET, &.{"authorization"})); + try testing.expect(try store.coversRequest(allocator, key, .GET, &.{"x-anything"})); } From 6aa0fffab39adcecadf213ca8f40fb4ed8215921 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 24 Sep 2026 12:29:57 -0700 Subject: [PATCH 20/21] collectAuthoredHeaders just once in CorsGate --- src/network/CorsGate.zig | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 5b904cdc9..bec549ed2 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -246,7 +246,7 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { }); lp.metrics.cors_check.incr(.preflight); - try self.fetchThenResume(transfer); + try self.fetchThenResume(transfer, authored.items); return .pending; } @@ -538,31 +538,16 @@ const CorsPreflightContext = struct { } }; -fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { +fn fetchThenResume(self: *CorsGate, transfer: *Transfer, authored_headers: []const []const u8) !void { const url = transfer.req.url; const origin = transfer.effectiveOrigin(); - var header_names: std.ArrayList([]const u8) = .empty; - for (transfer.req_headers.items) |hdr| { - if (hdr.source != .author) continue; - if (isSafelistedHeader(hdr.name, hdr.value)) continue; - try header_names.append( - transfer.arena.allocator(), - try std.ascii.allocLowerString(transfer.arena.allocator(), hdr.name), - ); - } - std.mem.sort([]const u8, header_names.items, {}, struct { - fn lessThan(_: void, a: []const u8, b: []const u8) bool { - return std.mem.lessThan(u8, a, b); - } - }.lessThan); - const cors_key = CorsKey{ .url = url, .origin = origin, .method = transfer.req.method, .wants_credentials = transfer.req.credentials_mode == .include, - .authored_headers = header_names.items, + .authored_headers = authored_headers, }; const key = try cors_key.build(transfer.arena.allocator()); @@ -585,8 +570,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { const referer: ?[]const u8 = transfer.findRequestHeader("referer"); - const owned_header_names = try arena.alloc([]const u8, header_names.items.len); - for (header_names.items, 0..) |name, i| { + const owned_header_names = try arena.alloc([]const u8, authored_headers.len); + for (authored_headers, 0..) |name, i| { owned_header_names[i] = try arena.dupe(u8, name); } @@ -642,8 +627,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { ); // Access-Control-Allow-Headers - if (header_names.items.len > 0) { - const request_headers_value = try std.mem.join(arena.allocator(), ",", header_names.items); + if (authored_headers.len > 0) { + const request_headers_value = try std.mem.join(arena.allocator(), ",", authored_headers); try fetch_transfer.setHeader( ACCESS_CONTROL_REQUEST_HEADERS, request_headers_value, From acd71e5f2991ce61b40005b26f055383f5fc0ae0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 24 Sep 2026 12:30:44 -0700 Subject: [PATCH 21/21] collect allowed headers from response --- src/network/CorsGate.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index bec549ed2..fdcd9b58a 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -427,13 +427,25 @@ const CorsPreflightContext = struct { const headers_wildcard = acah != null and std.mem.eql(u8, acah.?, "*") and !self.wants_credentials; + var allowed_headers: std.ArrayList([]const u8) = .empty; + if (!headers_wildcard) { + if (acah) |list| { + var it = std.mem.splitScalar(u8, list, ','); + while (it.next()) |raw| { + const token = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (token.len == 0) continue; + try allowed_headers.append(self.arena.allocator(), token); + } + } + } + try self.gate.network.cors_store.put( .{ .origin = self.origin, .target = self.url, .credentials = self.wants_credentials }, .{ .methods_wildcard = methods_wildcard, .methods = methods, .headers_wildcard = headers_wildcard, - .headers = if (headers_wildcard) &.{} else self.request_headers, + .headers = allowed_headers.items, .expires_at = lp.datetime.milliTimestamp(.real) + capped_ms, }, );