From b114382c44523c4aee19d8e9828be1e2acf85982 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 17 Aug 2026 07:53:48 -0700 Subject: [PATCH 01/41] add origin field to HttpClient Request --- src/browser/Frame.zig | 3 ++- src/browser/ScriptManager.zig | 3 +++ src/browser/webapi/SharedWorkerGlobalScope.zig | 1 + src/browser/webapi/Worker.zig | 1 + src/browser/webapi/WorkerGlobalScope.zig | 1 + src/browser/webapi/net/EventSource.zig | 1 + src/browser/webapi/net/Fetch.zig | 1 + src/browser/webapi/net/XMLHttpRequest.zig | 1 + src/network/HttpClient.zig | 16 ++++++++++++++++ src/network/RobotsGate.zig | 1 + 10 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index dff8f8bd5..b916acb33 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -857,7 +857,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo // do, they probably don't want the cached version. .skip_cache = self.parent == null, .throttle = self.parent == null, - .cookie_origin = opts.initiator_url, + .origin = self.origin, .resource_type = .document, .header_callback = frameHeaderDoneCallback, .data_callback = frameDataCallback, @@ -2382,6 +2382,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co const transfer = http_client.newRequest(.{ .url = resolved, .method = .GET, + .origin = self.origin, .resource_type = .stylesheet, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &self._http_owner) catch |err| { diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index bb0d9db08..7abcbf0d9 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -134,6 +134,7 @@ pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const .ctx = script, .url = owned_url, .method = .GET, + .origin = frame.origin, .resource_type = .script, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .header_callback = Script.headerCallback, @@ -345,6 +346,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e const transfer = try self.base.client.newRequest(.{ .url = remote_url, .method = .GET, + .origin = frame.origin, .resource_type = .script, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &frame._http_owner); @@ -389,6 +391,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e .ctx = script, .url = remote_url, .method = .GET, + .origin = frame.origin, .resource_type = .script, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .header_callback = Script.headerCallback, diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 84b5b9039..1e640c9b5 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -105,6 +105,7 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor .method = .GET, .url = owned_url, .resource_type = .worker, + .origin = frame.origin, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 7b6751250..e7c0e3421 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -106,6 +106,7 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker { .frame_id = self._frame_id, .loader_id = self._loader_id, .resource_type = if (self._type == .module) .script else .worker, + .origin = frame.origin, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 75c2d0e62..d2199aa40 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -430,6 +430,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! .url = resolved_url, .method = .GET, .resource_type = .worker, + .origin = self.origin, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &self._http_owner) catch |err| { log.warn(.http, "importScript", .{ .url = resolved_url, .err = err }); diff --git a/src/browser/webapi/net/EventSource.zig b/src/browser/webapi/net/EventSource.zig index 93534a2d1..a3ac7319b 100644 --- a/src/browser/webapi/net/EventSource.zig +++ b/src/browser/webapi/net/EventSource.zig @@ -179,6 +179,7 @@ fn connect(self: *EventSource) !void { .url = self._url, .method = .GET, .cookies = cookie_support, + .origin = exec.origin(), .resource_type = .eventsource, .streaming = true, .header_callback = httpHeaderDoneCallback, diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index e815214b1..36a12a62c 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -98,6 +98,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .include => true, .@"same-origin" => exec.isSameOrigin(request._url), }, + .origin = exec.origin(), .redirect = switch (request._redirect) { .follow => .follow, .manual => .manual, diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index de328b8ea..21b7f825d 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -316,6 +316,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v .method = self._method, .body = self._request_body, .cookies = cookie_support, + .origin = exec.origin(), .resource_type = .xhr, .timeout_ms = self._timeout, .header_callback = httpHeaderDoneCallback, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 902994691..936a6f4f1 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -626,10 +626,14 @@ pub fn newRequest(self: *Client, req: Request, owner: ?*Owner) anyerror!*Transfe .url => |url| .{ .url = try arena.dupeZ(u8, url) }, }; owned.cookie_origin = null; + if (req.credentials) |c| { owned.credentials = try arena.dupeZ(u8, c); } + const raw_origin: ?[]const u8 = req.origin orelse if (owner) |o| o.origin.* else null; + owned.origin = if (raw_origin) |origin| try arena.dupe(u8, origin) else null; + // The body can be larger, so callers can signal, via the // `body_outlives_request` flag that they guarantee that the body // will outlive the transfer (and thus doesn't need to be duped) @@ -1808,6 +1812,9 @@ pub const Request = struct { // initiator of a top-level navigation isn't the frame being navigated. cookie_origin: ?Cookie.SiteForCookies = null, + // The Origin of the Request. + origin: ?[]const u8, + // Requests that are internal to the browser and skip various layers, // these do not need to be deferred and do not obey robots.txt. internal: bool = false, @@ -4143,6 +4150,7 @@ fn testTransfer(arena: *lp.Arena) Transfer { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, }, @@ -4371,6 +4379,7 @@ test "HttpClient: fulfillIntercepted survives a done_callback that tears down th .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4588,6 +4597,7 @@ test "HttpClient: aborting a robots-parked transfer unlinks it from the gate" { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, }, @@ -4651,6 +4661,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" { .method = .POST, .url = "http://example.com/start", .body = "payload", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, @@ -4690,6 +4701,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" { .method = .POST, .url = "http://example.com/start", .body = "payload", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, @@ -4753,6 +4765,7 @@ test "HttpClient: fulfillIntercepted delivers a 3xx without a Location as the re .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4816,6 +4829,7 @@ test "HttpClient: abortParked survives an error_callback that tears down the own .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4889,6 +4903,7 @@ test "HttpClient: abort survives an error_callback that tears down the owner" { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .xhr, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4921,6 +4936,7 @@ test "HttpClient: abort survives an error_callback that tears down the owner" { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = "", .resource_type = .xhr, .shutdown_callback = noopShutdown, .ctx = &ctx, diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index 19a737ad9..3ff2e1e23 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -116,6 +116,7 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans .document_frame_id = transfer.req.document_frame_id, .loader_id = transfer.req.loader_id, .notification = transfer.req.notification, + .origin = null, .ctx = robots_ctx, .header_callback = RobotsContext.headerCallback, .data_callback = RobotsContext.dataCallback, From 212c806be57170f5ebfbac4414475264eacf424d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 17 Aug 2026 08:24:03 -0700 Subject: [PATCH 02/41] add basic CorsGate scaffold --- src/log.zig | 1 + src/network/CorsGate.zig | 120 +++++++++++++++++++++++++++++++++++++ src/network/HttpClient.zig | 41 +++++++++++-- 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/network/CorsGate.zig diff --git a/src/log.zig b/src/log.zig index 82ed15c6e..8afe305cc 100644 --- a/src/log.zig +++ b/src/log.zig @@ -42,6 +42,7 @@ pub const Scope = enum { telemetry, unknown_prop, websocket, + cors, }; pub const num_scopes = @typeInfo(Scope).@"enum".fields.len; diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig new file mode 100644 index 000000000..ea4753f31 --- /dev/null +++ b/src/network/CorsGate.zig @@ -0,0 +1,120 @@ +// 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 URL = @import("../browser/URL.zig"); +const ArenaPool = @import("../ArenaPool.zig"); + +const http = @import("http.zig"); +const Network = @import("Network.zig"); +const Transfer = @import("HttpClient.zig").Transfer; +const SingleFlight = @import("SingleFlight.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const CorsGate = @This(); + +network: *Network, +single_flight: SingleFlight, + +pub fn deinit(self: *CorsGate) void { + self.single_flight.deinit(); +} + +pub fn remove(self: *CorsGate, transfer: *Transfer) void { + self.single_flight.remove(transfer); +} + +fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { + var queued = self.single_flight.take(key) orelse return; + defer queued.deinit(self.single_flight.allocator); + + for (queued.items) |transfer| { + transfer.unpark(); + + if (!allowed) { + log.warn(.http, "blocked by cors (preflight)", .{ .url = transfer.req.url }); + transfer.failAsync(error.CorsBlocked); + continue; + } + + transfer.client.resumeAfterCors(transfer) catch |e| { + transfer.abortPipelineError(e); + }; + } +} + +fn isSafelistedContentType(value: []const u8) bool { + const semi = std.mem.indexOfScalar(u8, value, ';') orelse value.len; + const mime = std.mem.trim(u8, value[0..semi], &std.ascii.whitespace); + return std.ascii.eqlIgnoreCase(mime, "application/x-www-form-urlencoded") or + std.ascii.eqlIgnoreCase(mime, "multipart/form-data") or + std.ascii.eqlIgnoreCase(mime, "text/plain"); +} + +fn isSafelistedHeader(name: []const u8, value: []const u8) bool { + if (std.ascii.eqlIgnoreCase(name, "accept") or + std.ascii.eqlIgnoreCase(name, "accept-language") or + std.ascii.eqlIgnoreCase(name, "content-language")) + { + return true; + } + if (std.ascii.eqlIgnoreCase(name, "content-type")) { + return isSafelistedContentType(value); + } + return false; +} + +fn requiresPreflight(transfer: *const Transfer) bool { + const req = &transfer.req; + + switch (req.method) { + .GET, .HEAD, .POST => {}, + else => return true, + } + + for (transfer.req_headers.items) |hdr| { + // Only authored headers can trigger a preflight + if (hdr.source != .author) continue; + if (!isSafelistedHeader(hdr.name, hdr.value)) return true; + } + + return false; +} + +const Result = enum { allowed, blocked, pending }; + +pub fn check(self: *CorsGate, transfer: *Transfer) !Result { + _ = self; + const req = &transfer.req; + + if (req.origin) |origin| { + if (URL.isSameOrigin(req.url, origin)) { + log.debug(.cors, "same origin", .{ .url = req.url, .origin = origin }); + return .allowed; + } + } + + log.debug(.cors, "cross origin", .{ .url = req.url, .origin = req.origin orelse "null" }); + transfer._cors_cross_origin = true; + + return .blocked; +} diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 936a6f4f1..f871fc703 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -35,6 +35,7 @@ const http = @import("http.zig"); const Network = @import("Network.zig"); const Cache = @import("cache/Cache.zig"); const RobotsGate = @import("RobotsGate.zig"); +const CorsGate = @import("CorsGate.zig"); const UrlBlocklist = @import("UrlBlocklist.zig"); pub const BlockPattern = UrlBlocklist.Pattern; @@ -187,6 +188,7 @@ obey_robots: bool, http_version: lp.Config.HttpVersion, robots: RobotsGate, +cors: CorsGate, url_blocklist: ?UrlBlocklist, pub fn init(self: *Client, app: *lp.App) !void { @@ -233,6 +235,10 @@ pub fn init(self: *Client, app: *lp.App) !void { .network = network, .single_flight = .init(allocator), }, + .cors = .{ + .network = network, + .single_flight = .init(allocator), + }, .url_blocklist = url_blocklist, .arena_pool = &app.arena_pool, }; @@ -264,6 +270,7 @@ pub fn deinit(self: *Client) void { self.clearUrlBlocklist(); self.robots.deinit(); + self.cors.deinit(); self.blocking_requests.deinit(self.allocator); self.transfers.deinit(self.allocator); self.cache.maintenance(lp.datetime.timestamp(.real)); @@ -461,6 +468,7 @@ pub fn abort(self: *Client) void { // - self.robots.pending : each robots fetch's shutdown_callback // drops its entry; parked waiters unlink in their own deinit. std.debug.assert(self.robots.single_flight.count() == 0); + std.debug.assert(self.cors.single_flight.count() == 0); } } @@ -962,6 +970,7 @@ const SubmitFrom = enum { start, // Transfer.submit — a brand new request. redirect, // Followed 3xx. Same as .start, but a distinct name (e.g. for CDP) after_intercept, // Released by CDP + after_cors, // cors allowed the request. throttle, // the robots gate allowed the request. network, // released by throttle }; @@ -1015,9 +1024,18 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { return transfer.failAsync(error.UrlBlocked); } if (try self.cacheLookup(transfer)) { - // response came from the cache, we're done return; } + if (!transfer.req.internal) { + switch (try self.cors.check(transfer)) { + .allowed => {}, + .blocked => return transfer.failAsync(error.CorsBlocked), + .pending => return, + } + } + continue :sw SubmitFrom.after_cors; + }, + .after_cors => { if (self.obey_robots and !transfer.req.internal) { switch (try self.robots.check(transfer)) { .allowed => { @@ -1059,6 +1077,12 @@ pub fn resumeAfterRobots(self: *Client, transfer: *Transfer) !void { return self.pipeline(transfer, .throttle); } +// CorsGate resumption after a preflight resolves as allowed. Re-enters +// right after the CORS step (not .after_intercept) +pub fn resumeAfterCors(self: *Client, transfer: *Transfer) !void { + return self.pipeline(transfer, .after_cors); +} + fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 { for (headers) |hdr| { if (std.ascii.eqlIgnoreCase(hdr.name, name)) { @@ -2162,6 +2186,8 @@ pub const Transfer = struct { // everything and sits on client.graveyard _retired: bool = false, + _cors_cross_origin: bool = false, + pub const State = union(enum) { // Pre-commit. Only valid inside the request flow (Client.request // or a re-entry like continueTransfer / unpark) before any commit @@ -2214,6 +2240,9 @@ pub const Transfer = struct { // RobotsGate holds the transfer pending a robots.txt fetch. robots, + + // CorsGate holds the tranfer pending a CORS preflight. + cors, }; pub const HeaderResult = enum { @@ -2249,7 +2278,7 @@ pub const Transfer = struct { return; } switch (self.state.parked) { - .robots => {}, + .robots, .cors => {}, .intercept_request, .intercept_auth => { lp.assert(self.client.intercepted > 0, "Transfer.leaveIntercept", .{ .value = self.client.intercepted }); self.client.intercepted -= 1; @@ -2388,8 +2417,12 @@ pub const Transfer = struct { // And for the robots gate: RobotsGate.pending holds a raw *Transfer // while we're parked. - if (self.state == .parked and self.state.parked == .robots) { - self.client.robots.remove(self); + if (self.state == .parked) { + switch (self.state.parked) { + .cors => self.client.cors.remove(self), + .robots => self.client.robots.remove(self), + .intercept_auth, .intercept_request => {}, + } } // A pending revalidation entry owns cache resources (possibly an From ec45dccacfa31d9d857510d287b7636b34c56b88 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 17 Aug 2026 08:35:43 -0700 Subject: [PATCH 03/41] add obey_cors option --- src/Config.zig | 8 ++++++++ src/help.zon | 3 +++ src/network/HttpClient.zig | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 262ab020e..c9f215b7a 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -247,6 +247,7 @@ pub const LoadResources = packed struct(u4) { /// Common CLI args. const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, + .{ .name = "obey_cors", .type = bool }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, @@ -555,6 +556,13 @@ pub fn httpVersion(self: *const Config) HttpVersion { }; } +pub fn obeyCors(self: *const Config) bool { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.obey_cors, + else => unreachable, + }; +} + pub fn watchdogMs(self: *const Config) ?u32 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| { diff --git a/src/help.zon b/src/help.zon index d3aec0b9a..3451600e4 100644 --- a/src/help.zon +++ b/src/help.zon @@ -453,6 +453,9 @@ \\ --obey-robots \\ Fetches and obeys robots.txt of the target page. \\ Defaults to false. + \\ --obey-cors + \\ Obeys CORS rules, executing preflights as needed. + \\ Defaults to false. \\ --proxy-bearer-token \\ Token sent for bearer authentication with the proxy: \\ Proxy-Authorization: Bearer . diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index f871fc703..9a3c8c400 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -182,6 +182,7 @@ cache: *Cache, // Cached config decisions, resolved once at init. serve_mode: bool, obey_robots: bool, +obey_cors: bool, // Applied to every transfer at configureConn, so a CDP change takes effect // on the next request, not on in-flight ones. @@ -231,6 +232,7 @@ pub fn init(self: *Client, app: *lp.App) !void { .serve_mode = config.mode == .serve, .obey_robots = config.obeyRobots(), .http_version = config.httpVersion(), + .obey_cors = config.obeyCors(), .robots = .{ .network = network, .single_flight = .init(allocator), @@ -1026,7 +1028,7 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { if (try self.cacheLookup(transfer)) { return; } - if (!transfer.req.internal) { + if (self.obey_cors and !transfer.req.internal) { switch (try self.cors.check(transfer)) { .allowed => {}, .blocked => return transfer.failAsync(error.CorsBlocked), From 4e79f2481475ed4d3f25ac1445f93b2bb3af705f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 17 Aug 2026 09:19:31 -0700 Subject: [PATCH 04/41] basic validation without preflighting --- src/network/CorsGate.zig | 56 ++++++++++++++++++++++++++++++++++++-- src/network/HttpClient.zig | 12 +++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index ea4753f31..eb20d5dda 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -26,6 +26,7 @@ const http = @import("http.zig"); const Network = @import("Network.zig"); const Transfer = @import("HttpClient.zig").Transfer; const SingleFlight = @import("SingleFlight.zig"); +const HttpClient = @import("HttpClient.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -103,7 +104,6 @@ fn requiresPreflight(transfer: *const Transfer) bool { const Result = enum { allowed, blocked, pending }; pub fn check(self: *CorsGate, transfer: *Transfer) !Result { - _ = self; const req = &transfer.req; if (req.origin) |origin| { @@ -113,8 +113,60 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { } } - log.debug(.cors, "cross origin", .{ .url = req.url, .origin = req.origin orelse "null" }); transfer._cors_cross_origin = true; + if (!requiresPreflight(transfer)) { + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = req.origin orelse "null", + .preflight = false, + }); + return .allowed; + } + + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = req.origin orelse "null", + .preflight = true, + }); + + _ = self; return .blocked; + + // try self.fetchThenResumse(transfer); + // return .pendind; +} + +pub fn validateResponse(transfer: *Transfer) !void { + const req = &transfer.req; + const allow_origin = HttpClient.findHeader(transfer.res.headers, "access-control-allow-origin"); + + if (allow_origin == null) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "missing acao" }); + return error.CorsBlocked; + } + + const wants_credentials = req.credentials != null or transfer.findRequestHeader("Cookie") != null; + + if (!std.mem.eql(u8, allow_origin.?, "*")) { + const origin = req.origin orelse { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "opaque origin" }); + return error.CorsBlocked; + }; + if (!std.mem.eql(u8, allow_origin.?, origin)) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "origin mismatch", .allow_origin = allow_origin.?, .origin = origin }); + return error.CorsBlocked; + } + } else if (wants_credentials) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "wildcard with credentials" }); + return error.CorsBlocked; + } + + if (wants_credentials) { + const allow_creds = HttpClient.findHeader(transfer.res.headers, "access-control-allow-credentials"); + if (allow_creds == null or !std.mem.eql(u8, allow_creds.?, "true")) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "credentials not allowed" }); + return error.CorsBlocked; + } + } } diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 9a3c8c400..891c5e9dc 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1085,7 +1085,7 @@ pub fn resumeAfterCors(self: *Client, transfer: *Transfer) !void { return self.pipeline(transfer, .after_cors); } -fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 { +pub fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 { for (headers) |hdr| { if (std.ascii.eqlIgnoreCase(hdr.name, name)) { return hdr.value; @@ -1690,6 +1690,16 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T try transfer.materializeResponse(msg.conn, .{}); + // Validate the headers for the response with CORS. + if (transfer._cors_cross_origin) { + CorsGate.validateResponse(transfer) catch |err| { + self.removeConn(msg.conn); + transfer._conn = null; + transfer.failAsync(err); + return true; + }; + } + // Latency is only meaningful for responses that hit the network (cache // and synthetic responses never reach processOneMessage). if (!transfer.req.internal) { From f428dc8e52bdba09e16b72051157e5b99e5c5b8b Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 17 Aug 2026 09:29:48 -0700 Subject: [PATCH 05/41] add origin header in CorsGate on cross origin --- src/network/CorsGate.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index eb20d5dda..8b2ccab5e 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -114,11 +114,13 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { } transfer._cors_cross_origin = true; + const origin = req.origin orelse "null"; + try transfer.setHeader("Origin", origin, .{}); if (!requiresPreflight(transfer)) { log.debug(.cors, "cross origin", .{ .url = req.url, - .origin = req.origin orelse "null", + .origin = origin, .preflight = false, }); return .allowed; From 977874ce5fb388a7731e71faa0f55001db31eb07 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 18 Aug 2026 12:42:02 -0700 Subject: [PATCH 06/41] initial preflighting --- src/network/CorsGate.zig | 276 +++++++++++++++++++++++++++++++++++---- 1 file changed, 251 insertions(+), 25 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 8b2ccab5e..2a480dfb5 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -36,6 +36,17 @@ const CorsGate = @This(); network: *Network, single_flight: SingleFlight, +// CORS Request Headers +const ORIGIN = "origin"; +const ACCESS_CONTROL_REQUEST_METHOD = "access-control-request-method"; +const ACCESS_CONTROL_REQUEST_HEADERS = "access-control-request-headers"; + +// CORS Response Headers +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_MAX_AGE = "access-control-max-age"; + pub fn deinit(self: *CorsGate) void { self.single_flight.deinit(); } @@ -115,7 +126,7 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { transfer._cors_cross_origin = true; const origin = req.origin orelse "null"; - try transfer.setHeader("Origin", origin, .{}); + try transfer.setHeader(ORIGIN, origin, .{}); if (!requiresPreflight(transfer)) { log.debug(.cors, "cross origin", .{ @@ -128,46 +139,261 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { log.debug(.cors, "cross origin", .{ .url = req.url, - .origin = req.origin orelse "null", + .origin = origin, .preflight = true, }); - _ = self; - return .blocked; + try self.fetchThenResume(transfer); + return .pending; +} - // try self.fetchThenResumse(transfer); - // return .pendind; +const CorsPreflightContext = struct { + gate: *CorsGate, + arena: *lp.Arena, + + url: [:0]const u8, + origin: []const u8, + method: http.Method, + request_headers: []const []const u8, + + allowed: bool = false, + + fn validateHeaders( + self: *CorsPreflightContext, + acao: ?[]const u8, + acam: ?[]const u8, + acah: ?[]const u8, + ) bool { + // Access-Control-Allow-Origin + const allow_origin = acao orelse { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acao" }); + return false; + }; + + if (!std.mem.eql(u8, allow_origin, "*") and !std.mem.eql(u8, allow_origin, self.origin)) { + log.debug(.cors, "preflight blocked", .{ + .url = self.url, + .reason = "origin mismatch", + .allow_origin = allow_origin, + .origin = self.origin, + }); + return false; + } + + // Access-Control-Allow-Methods + const allow_methods = acam orelse { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acam" }); + return false; + }; + + if (!std.mem.eql(u8, allow_methods, "*") and !methodAllowed(allow_methods, self.method)) { + log.debug(.cors, "preflight blocked", .{ + .url = self.url, + .reason = "method not allowed", + .allow_methods = allow_methods, + .method = @tagName(self.method), + }); + return false; + } + + // Access-Control-Allow-Headers + if (self.request_headers.len > 0) { + const allow_headers = acah orelse { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acah" }); + return false; + }; + + if (!std.mem.eql(u8, allow_headers, "*")) { + for (self.request_headers) |name| { + if (!headerAllowed(allow_headers, name)) { + log.debug(.cors, "preflight blocked", .{ + .url = self.url, + .reason = "header not allowed", + .allow_headers = allow_headers, + .header = name, + }); + return false; + } + } + } + } + + return true; + } + + fn methodAllowed(list: []const u8, method: http.Method) bool { + const method_name = @tagName(method); + var it = std.mem.splitScalar(u8, list, ','); + while (it.next()) |raw| { + const token = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (std.mem.eql(u8, token, method_name)) return true; + } + return false; + } + + fn headerAllowed(list: []const u8, name: []const u8) bool { + var it = std.mem.splitScalar(u8, list, ','); + while (it.next()) |raw| { + const tok = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (std.ascii.eqlIgnoreCase(tok, name)) return true; + } + return false; + } + + fn headerCallback(transfer: *Transfer) anyerror!Transfer.HeaderResult { + const self: *CorsPreflightContext = @ptrCast(@alignCast(transfer.req.ctx)); + + // Must be 2xx + if (transfer.responseStatus()) |status| { + switch (status) { + 200...299 => {}, + else => |s| { + log.debug(.cors, "failed preflight", .{ .url = self.url, .status = s }); + self.allowed = false; + return .proceed; + }, + } + } + + var acao: ?[]const u8 = null; + var acam: ?[]const u8 = null; + var acah: ?[]const u8 = null; + + var iter = transfer.responseHeaderIterator(); + while (iter.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_ORIGIN, hdr.name)) { + acao = hdr.value; + } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_METHODS, hdr.name)) { + acam = hdr.value; + } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_HEADERS, hdr.name)) { + acah = hdr.value; + } + } + + self.allowed = self.validateHeaders(acao, acam, acah); + + return .proceed; + } + + fn doneCallback(ctx_ptr: *anyopaque) anyerror!void { + const self: *CorsPreflightContext = @ptrCast(@alignCast(ctx_ptr)); + self.resolve(self.allowed); + } + + fn errorCallback(ctx_ptr: *anyopaque, err: anyerror) void { + const self: *CorsPreflightContext = @ptrCast(@alignCast(ctx_ptr)); + log.warn(.cors, "preflight error", .{ .url = self.url, .err = err }); + + self.resolve(false); + } + + fn shutdownCallback(ctx_ptr: *anyopaque) void { + const self: *CorsPreflightContext = @ptrCast(@alignCast(ctx_ptr)); + log.debug(.cors, "preflight shutdown", .{ .url = self.url }); + + const gate = self.gate; + const arena = self.arena; + gate.single_flight.discard(self.url); + arena.release(); + } + + fn resolve(self: *CorsPreflightContext, allowed: bool) void { + const gate = self.gate; + const arena = self.arena; + gate.flushPending(self.url, allowed); + arena.release(); + } +}; + +fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { + const client = transfer.client; + const arena_pool = client.arena_pool; + + const arena = try arena_pool.acquire(.tiny, "CorsGate.CorsPreflightContext"); + errdefer arena_pool.release(arena); + + const owned_url = try arena.dupeZ(u8, transfer.req.url); + + 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(arena.allocator(), try arena.dupe(u8, hdr.name)); + } + + const ctx = try arena.create(CorsPreflightContext); + ctx.* = .{ + .gate = self, + .arena = arena, + .url = owned_url, + + .origin = try arena.dupe(u8, transfer.req.origin orelse "null"), + .method = transfer.req.method, + .request_headers = header_names.items, + }; + + const fetch_transfer = try client.newRequest(.{ + .url = owned_url, + .method = .OPTIONS, + .internal = true, + .resource_type = .fetch, + .frame_id = transfer.req.frame_id, + .document_frame_id = transfer.req.document_frame_id, + .loader_id = transfer.req.loader_id, + .notification = transfer.req.notification, + .cookie_jar = null, + .cookie_origin = transfer.req.cookie_origin, + .origin = transfer.req.origin, + .ctx = ctx, + .header_callback = CorsPreflightContext.headerCallback, + .done_callback = CorsPreflightContext.doneCallback, + .error_callback = CorsPreflightContext.errorCallback, + .shutdown_callback = CorsPreflightContext.shutdownCallback, + }, null); + errdefer fetch_transfer.deinit(); + + // Origin + try fetch_transfer.setHeader( + ORIGIN, + transfer.req.origin orelse "null", + .{}, + ); + + // Access-Control-Allow-Methods + try fetch_transfer.setHeader( + ACCESS_CONTROL_REQUEST_METHOD, + @tagName(transfer.req.method), + .{}, + ); + + // Access-Control-Allow-Headers + if (header_names.items.len > 0) { + const request_headers_value = try std.mem.join(arena.allocator(), ",", header_names.items); + try fetch_transfer.setHeader( + ACCESS_CONTROL_REQUEST_HEADERS, + request_headers_value, + .{}, + ); + } + + fetch_transfer.submit() catch {}; } pub fn validateResponse(transfer: *Transfer) !void { const req = &transfer.req; - const allow_origin = HttpClient.findHeader(transfer.res.headers, "access-control-allow-origin"); - - if (allow_origin == null) { + const allow_origin = HttpClient.findHeader(transfer.res.headers, ACCESS_CONTROL_ALLOW_ORIGIN) orelse { log.warn(.cors, "blocked", .{ .url = req.url, .reason = "missing acao" }); return error.CorsBlocked; - } + }; - const wants_credentials = req.credentials != null or transfer.findRequestHeader("Cookie") != null; - - if (!std.mem.eql(u8, allow_origin.?, "*")) { + if (!std.mem.eql(u8, allow_origin, "*")) { const origin = req.origin orelse { log.warn(.cors, "blocked", .{ .url = req.url, .reason = "opaque origin" }); return error.CorsBlocked; }; - if (!std.mem.eql(u8, allow_origin.?, origin)) { - log.warn(.cors, "blocked", .{ .url = req.url, .reason = "origin mismatch", .allow_origin = allow_origin.?, .origin = origin }); - return error.CorsBlocked; - } - } else if (wants_credentials) { - log.warn(.cors, "blocked", .{ .url = req.url, .reason = "wildcard with credentials" }); - return error.CorsBlocked; - } - if (wants_credentials) { - const allow_creds = HttpClient.findHeader(transfer.res.headers, "access-control-allow-credentials"); - if (allow_creds == null or !std.mem.eql(u8, allow_creds.?, "true")) { - log.warn(.cors, "blocked", .{ .url = req.url, .reason = "credentials not allowed" }); + if (!std.mem.eql(u8, allow_origin, origin)) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "origin mismatch", .allow_origin = allow_origin.?, .origin = origin }); return error.CorsBlocked; } } From b976894315aaf67720a13e681c36c8229d101ecc Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 18 Aug 2026 13:00:44 -0700 Subject: [PATCH 07/41] properly hook up single flight for CorsGate --- src/network/CorsGate.zig | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 2a480dfb5..53d98a7ec 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -248,7 +248,7 @@ const CorsPreflightContext = struct { switch (status) { 200...299 => {}, else => |s| { - log.debug(.cors, "failed preflight", .{ .url = self.url, .status = s }); + log.debug(.cors, "preflight blocked", .{ .url = self.url, .status = s }); self.allowed = false; return .proceed; }, @@ -271,7 +271,6 @@ const CorsPreflightContext = struct { } self.allowed = self.validateHeaders(acao, acam, acah); - return .proceed; } @@ -306,6 +305,15 @@ const CorsPreflightContext = struct { }; fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { + const url = transfer.req.url; + + const result = try self.single_flight.enter(url, transfer, .cors); + if (result == .queued) return; + errdefer { + self.single_flight.discard(url); + transfer.unpark(); + } + const client = transfer.client; const arena_pool = client.arena_pool; @@ -393,7 +401,12 @@ pub fn validateResponse(transfer: *Transfer) !void { }; if (!std.mem.eql(u8, allow_origin, origin)) { - log.warn(.cors, "blocked", .{ .url = req.url, .reason = "origin mismatch", .allow_origin = allow_origin.?, .origin = origin }); + log.warn(.cors, "blocked", .{ + .url = req.url, + .reason = "origin mismatch", + .allow_origin = allow_origin, + .origin = origin, + }); return error.CorsBlocked; } } From cf37a94f16b7f8aeabc6584ed36a4200842720ac Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 18 Aug 2026 13:04:48 -0700 Subject: [PATCH 08/41] get rid of blocked on CorsGate check result --- src/network/CorsGate.zig | 2 +- src/network/HttpClient.zig | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 53d98a7ec..cb97dc2e1 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -112,7 +112,7 @@ fn requiresPreflight(transfer: *const Transfer) bool { return false; } -const Result = enum { allowed, blocked, pending }; +const Result = enum { allowed, pending }; pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const req = &transfer.req; diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 891c5e9dc..4734588dc 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1031,7 +1031,6 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { if (self.obey_cors and !transfer.req.internal) { switch (try self.cors.check(transfer)) { .allowed => {}, - .blocked => return transfer.failAsync(error.CorsBlocked), .pending => return, } } From f31b32ac4ea050d7b2fd8a535675525ec82be361 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 18 Aug 2026 13:05:54 -0700 Subject: [PATCH 09/41] don't store network in CorsGate --- src/network/CorsGate.zig | 1 - src/network/HttpClient.zig | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index cb97dc2e1..39bf55b58 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -33,7 +33,6 @@ const Allocator = std.mem.Allocator; const CorsGate = @This(); -network: *Network, single_flight: SingleFlight, // CORS Request Headers diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 4734588dc..cbfcdc529 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -237,10 +237,7 @@ pub fn init(self: *Client, app: *lp.App) !void { .network = network, .single_flight = .init(allocator), }, - .cors = .{ - .network = network, - .single_flight = .init(allocator), - }, + .cors = .{ .single_flight = .init(allocator) }, .url_blocklist = url_blocklist, .arena_pool = &app.arena_pool, }; From ddfa034310d250d162a3f5103cc682a25cc0f64d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 18 Aug 2026 14:43:03 -0700 Subject: [PATCH 10/41] add credentials_mode for proper CORS credentials handling --- src/browser/webapi/net/Fetch.zig | 5 ++ src/browser/webapi/net/XMLHttpRequest.zig | 1 + src/network/CorsGate.zig | 60 ++++++++++++++++++++--- src/network/HttpClient.zig | 20 ++++++-- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 36a12a62c..c198d68ac 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -98,6 +98,11 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .include => true, .@"same-origin" => exec.isSameOrigin(request._url), }, + .credentials_mode = switch (request._credentials) { + .omit => .omit, + .@"same-origin" => .same_origin, + .include => .include, + }, .origin = exec.origin(), .redirect = switch (request._redirect) { .follow => .follow, diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 21b7f825d..df65cf72e 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -316,6 +316,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v .method = self._method, .body = self._request_body, .cookies = cookie_support, + .credentials_mode = if (self._with_credentials) .include else .same_origin, .origin = exec.origin(), .resource_type = .xhr, .timeout_ms = self._timeout, diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 39bf55b58..38fab7f57 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -44,7 +44,7 @@ const ACCESS_CONTROL_REQUEST_HEADERS = "access-control-request-headers"; 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_MAX_AGE = "access-control-max-age"; +const ACCESS_CONTROL_ALLOW_CREDENTIALS = "access-control-allow-credentials"; pub fn deinit(self: *CorsGate) void { self.single_flight.deinit(); @@ -154,6 +154,7 @@ const CorsPreflightContext = struct { origin: []const u8, method: http.Method, request_headers: []const []const u8, + wants_credentials: bool, allowed: bool = false, @@ -162,6 +163,7 @@ const CorsPreflightContext = struct { acao: ?[]const u8, acam: ?[]const u8, acah: ?[]const u8, + acac: ?[]const u8, ) bool { // Access-Control-Allow-Origin const allow_origin = acao orelse { @@ -169,7 +171,14 @@ const CorsPreflightContext = struct { return false; }; - if (!std.mem.eql(u8, allow_origin, "*") and !std.mem.eql(u8, allow_origin, self.origin)) { + const is_wildcard_origin = std.mem.eql(u8, allow_origin, "*"); + + if (is_wildcard_origin and self.wants_credentials) { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "wildcard origin with credentials" }); + return false; + } + + if (!is_wildcard_origin and !std.mem.eql(u8, allow_origin, self.origin)) { log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "origin mismatch", @@ -179,13 +188,27 @@ const CorsPreflightContext = struct { return false; } + // Access-Control-Allow-Credentials + if (self.wants_credentials) { + const allow_credentials = acac orelse { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acac" }); + return false; + }; + + if (!std.mem.eql(u8, allow_credentials, "true")) { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "credentials not allowed", .allow_credentials = acac }); + return false; + } + } + // Access-Control-Allow-Methods const allow_methods = acam orelse { log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acam" }); return false; }; - if (!std.mem.eql(u8, allow_methods, "*") and !methodAllowed(allow_methods, self.method)) { + const methods_wildcard = std.mem.eql(u8, allow_methods, "*") and !self.wants_credentials; + if (!methods_wildcard and !methodAllowed(allow_methods, self.method)) { log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "method not allowed", @@ -202,7 +225,8 @@ const CorsPreflightContext = struct { return false; }; - if (!std.mem.eql(u8, allow_headers, "*")) { + const headers_wildcard = std.mem.eql(u8, allow_headers, "*") and !self.wants_credentials; + if (!headers_wildcard) { for (self.request_headers) |name| { if (!headerAllowed(allow_headers, name)) { log.debug(.cors, "preflight blocked", .{ @@ -257,6 +281,7 @@ const CorsPreflightContext = struct { var acao: ?[]const u8 = null; var acam: ?[]const u8 = null; var acah: ?[]const u8 = null; + var acac: ?[]const u8 = null; var iter = transfer.responseHeaderIterator(); while (iter.next()) |hdr| { @@ -266,10 +291,12 @@ const CorsPreflightContext = struct { acam = hdr.value; } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_HEADERS, hdr.name)) { acah = hdr.value; + } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_CREDENTIALS, hdr.name)) { + acac = hdr.value; } } - self.allowed = self.validateHeaders(acao, acam, acah); + self.allowed = self.validateHeaders(acao, acam, acah, acac); return .proceed; } @@ -337,6 +364,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { .origin = try arena.dupe(u8, transfer.req.origin orelse "null"), .method = transfer.req.method, .request_headers = header_names.items, + .wants_credentials = transfer.req.credentials_mode == .include, }; const fetch_transfer = try client.newRequest(.{ @@ -393,7 +421,15 @@ pub fn validateResponse(transfer: *Transfer) !void { return error.CorsBlocked; }; - if (!std.mem.eql(u8, allow_origin, "*")) { + const wants_credentials = req.credentials_mode == .include; + const is_wildcard_origin = std.mem.eql(u8, allow_origin, "*"); + + if (is_wildcard_origin and wants_credentials) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "wildcard origin with credentials" }); + return error.CorsBlocked; + } + + if (!is_wildcard_origin) { const origin = req.origin orelse { log.warn(.cors, "blocked", .{ .url = req.url, .reason = "opaque origin" }); return error.CorsBlocked; @@ -409,4 +445,16 @@ pub fn validateResponse(transfer: *Transfer) !void { return error.CorsBlocked; } } + + if (wants_credentials) { + const allow_creds = HttpClient.findHeader(transfer.res.headers, ACCESS_CONTROL_ALLOW_CREDENTIALS) orelse { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "missing acac" }); + return error.CorsBlocked; + }; + + if (!std.mem.eql(u8, allow_creds, "true")) { + log.warn(.cors, "blocked", .{ .url = req.url, .reason = "credentials not allowed", .allow_credentials = allow_creds }); + return error.CorsBlocked; + } + } } diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index cbfcdc529..8a2fc2eab 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -634,8 +634,8 @@ pub fn newRequest(self: *Client, req: Request, owner: ?*Owner) anyerror!*Transfe }; owned.cookie_origin = null; - if (req.credentials) |c| { - owned.credentials = try arena.dupeZ(u8, c); + if (req.basic_auth_credentials) |c| { + owned.basic_auth_credentials = try arena.dupeZ(u8, c); } const raw_origin: ?[]const u8 = req.origin orelse if (owner) |o| o.origin.* else null; @@ -1808,13 +1808,23 @@ pub const Request = struct { // ten segments, versus a TCP handshake plus a TLS one. const HEADERS_ONLY_DRAIN_MAX: usize = 16 * 1024; + pub const CredentialsMode = enum { + // Never send credentials, even same-origin. + omit, + // Send credentials only for same-origin requests. + same_origin, + // Always send credentials, including cross-origin. + include, + }; + method: Method, url: [:0]const u8, body: ?[]const u8 = null, resource_type: ResourceType, redirect: RedirectMode = .follow, referrer_policy: ?referrer.Policy = null, - credentials: ?[:0]const u8 = null, + basic_auth_credentials: ?[:0]const u8 = null, + credentials_mode: CredentialsMode = .same_origin, timeout_ms: u32 = 0, skip_cache: bool = false, @@ -3097,7 +3107,7 @@ pub const Transfer = struct { } // add credentials - if (req.credentials) |creds| { + if (req.basic_auth_credentials) |creds| { if (self._auth_challenge != null and self._auth_challenge.?.source == .proxy) { try conn.setProxyCredentials(creds); } else { @@ -3282,7 +3292,7 @@ pub const Transfer = struct { } pub fn updateCredentials(self: *Transfer, userpwd: [:0]const u8) void { - self.req.credentials = userpwd; + self.req.basic_auth_credentials = userpwd; } pub const RequestHeader = struct { From d3c0291bd1d2e8442b7038e4a4c4822c688a37d0 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 19 Aug 2026 06:31:11 -0700 Subject: [PATCH 11/41] add request mode for Fetch --- src/browser/webapi/net/Fetch.zig | 23 +++++++++++++++++++++++ src/browser/webapi/net/Request.zig | 22 ++++++++++++++++++++++ src/network/CorsGate.zig | 9 +++++++++ src/network/HttpClient.zig | 26 ++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index c198d68ac..74f50bac3 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -43,6 +43,7 @@ _resolver: js.PromiseResolver.Global, _owns_response: bool, _signal: ?*AbortSignal, _manual_redirect: bool, +_no_cors: bool, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; @@ -81,6 +82,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis ._owns_response = true, ._signal = request._signal, ._manual_redirect = request._redirect == .manual, + ._no_cors = request._mode == .@"no-cors", }; if (comptime lp.IS_DEBUG) { @@ -103,6 +105,12 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .@"same-origin" => .same_origin, .include => .include, }, + .request_mode = switch (request._mode) { + .cors => .cors, + .@"no-cors" => .no_cors, + .@"same-origin" => .same_origin, + .navigate => .navigate, + }, .origin = exec.origin(), .redirect = switch (request._redirect) { .follow => .follow, @@ -168,6 +176,17 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { res._url = try arena.dupeZ(u8, transfer.req.url); res._is_redirected = transfer.redirectCount().? > 0; + // no-cors mode: regardless of what the server returned, JS only ever sees + // an opaque response — status 0, no headers, no body, url "". + if (self._no_cors) { + res._status = 0; + res._status_text = ""; + res._url = ""; + res._type = .@"opaque"; + res._is_redirected = false; + return .proceed; + } + // redirect: "manual" surfaces the unfollowed 3xx as an opaque-redirect // filtered response: status 0, no headers, no body. if (self._manual_redirect and HttpClient.isRedirectStatus(res._status)) { @@ -216,6 +235,10 @@ fn httpDataCallback(transfer: *Transfer, data: []const u8) !void { } } + if (self._no_cors) { + return; + } + try self._buf.appendSlice(self._response._arena.allocator(), data); } diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 18e8d1ff3..115fdcffa 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -46,6 +46,7 @@ _arena: *lp.Arena, _cache: Cache, _credentials: Credentials, _redirect: Redirect, +_mode: Mode, _signal: ?*AbortSignal, _body_used: bool = false, @@ -62,6 +63,7 @@ pub const InitOpts = struct { method: ?[]const u8 = null, priority: ?[]const u8 = null, redirect: Redirect = .follow, + mode: Mode = .cors, signal: ?*AbortSignal = null, }; @@ -91,6 +93,14 @@ const Cache = enum { pub const js_enum_from_string = true; }; +const Mode = enum { + cors, + @"no-cors", + @"same-origin", + navigate, + pub const js_enum_from_string = true; +}; + pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { const arena = try exec.getPinnedArena(.medium, "Request"); errdefer arena.release(); @@ -148,6 +158,11 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { .request => |r| r._signal, }; + const mode = switch (input) { + .url => opts.mode, + .request => |r| if (opts_ != null) opts.mode else r._mode, + }; + const self = try arena.create(Request); self.* = .{ ._url = url, @@ -157,6 +172,7 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request { ._cache = opts.cache, ._credentials = opts.credentials, ._redirect = opts.redirect, + ._mode = mode, ._body = body, ._signal = signal, }; @@ -216,6 +232,10 @@ pub fn getRedirect(self: *const Request) []const u8 { return @tagName(self._redirect); } +pub fn getMode(self: *const Request) []const u8 { + return @tagName(self._mode); +} + pub fn getSignal(self: *const Request) ?*AbortSignal { return self._signal; } @@ -356,6 +376,7 @@ pub fn clone(self: *const Request, exec: *const Execution) !*Request { ._cache = self._cache, ._credentials = self._credentials, ._redirect = self._redirect, + ._mode = self._mode, ._body = if (self._body) |b| try arena.dupe(u8, b) else null, ._signal = self._signal, }; @@ -379,6 +400,7 @@ pub const JsApi = struct { pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); pub const redirect = bridge.accessor(Request.getRedirect, null, .{}); + pub const mode = bridge.accessor(Request.getMode, null, .{}); pub const signal = bridge.accessor(Request.getSignal, null, .{}); pub const bodyUsed = bridge.accessor(Request.getBodyUsed, null, .{}); pub const blob = bridge.function(Request.blob, .{}); diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 38fab7f57..59e4af9e0 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -127,6 +127,15 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const origin = req.origin orelse "null"; try transfer.setHeader(ORIGIN, origin, .{}); + if (req.request_mode == .no_cors) { + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = origin, + .mode = "no-cors", + }); + return .allowed; + } + if (!requiresPreflight(transfer)) { log.debug(.cors, "cross origin", .{ .url = req.url, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 8a2fc2eab..f999278c3 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -417,6 +417,15 @@ fn isHostAdblocked(self: *const Client, url: [:0]const u8) bool { return blocker.matchHostname(hostname) == .blocked; } +fn isCrossOriginModeAllowed(transfer: *const Transfer) bool { + const req = &transfer.req; + if (req.request_mode != .same_origin) { + return true; + } + const origin = req.origin orelse return false; + return URL.isSameOrigin(req.url, origin); +} + pub fn getUserAgent(self: *const Client) [:0]const u8 { return self.user_agent_override orelse self.network.config.http_headers.user_agent; } @@ -1025,6 +1034,15 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { if (try self.cacheLookup(transfer)) { return; } + + if (!isCrossOriginModeAllowed(transfer) and !transfer.req.internal) { + log.warn(.http, "blocked by mode", .{ + .url = transfer.req.url, + .mode = @tagName(transfer.req.request_mode), + }); + return transfer.failAsync(error.ModeBlocked); + } + if (self.obey_cors and !transfer.req.internal) { switch (try self.cors.check(transfer)) { .allowed => {}, @@ -1817,6 +1835,13 @@ pub const Request = struct { include, }; + pub const RequestMode = enum { + cors, + no_cors, + same_origin, + navigate, + }; + method: Method, url: [:0]const u8, body: ?[]const u8 = null, @@ -1825,6 +1850,7 @@ pub const Request = struct { referrer_policy: ?referrer.Policy = null, basic_auth_credentials: ?[:0]const u8 = null, credentials_mode: CredentialsMode = .same_origin, + request_mode: RequestMode = .cors, timeout_ms: u32 = 0, skip_cache: bool = false, From da00a90c0267b3af55bff086a5cbd7d3c9d65422 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 20 Aug 2026 21:59:23 -0700 Subject: [PATCH 12/41] set cors cross origin on transfer after no-cors check --- src/network/CorsGate.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 59e4af9e0..886bf4454 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -123,7 +123,6 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { } } - transfer._cors_cross_origin = true; const origin = req.origin orelse "null"; try transfer.setHeader(ORIGIN, origin, .{}); @@ -136,6 +135,8 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .allowed; } + transfer._cors_cross_origin = true; + if (!requiresPreflight(transfer)) { log.debug(.cors, "cross origin", .{ .url = req.url, From e9449b2276e5d61fc1cc4b7296c7ef2a1c438ea4 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 20 Aug 2026 22:19:07 -0700 Subject: [PATCH 13/41] follow create a potential cors request rules in ScriptManager --- src/browser/ScriptManager.zig | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 7abcbf0d9..c9c8650a9 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -97,6 +97,38 @@ pub fn tailHook(base: *ScriptManagerBase) void { } } +const CorsSettings = struct { + request_mode: HttpClient.Request.RequestMode, + credentials_mode: HttpClient.Request.CredentialsMode, +}; + +// Follows the "create a potential-CORS request" +// (https://html.spec.whatwg.org/multipage/urls-and-fetching.html#create-a-potential-cors-request) +// in order to properly set the request_mode and credentials_mode. +fn corsSettings(element: ?*Element, is_module: bool) CorsSettings { + const mode: enum { no_cors, anonymous, use_credentials } = blk: { + const co = if (element) |e| e.getAttributeSafe(comptime .wrap("crossorigin")) else null; + + const value = co orelse { + // Missing-value default: No CORS for classic scripts, Anonymous for modules. + break :blk if (is_module) .anonymous else .no_cors; + }; + + if (std.ascii.eqlIgnoreCase(value, "use-credentials")) { + break :blk .use_credentials; + } + + // Empty-value and invalid-value defaults are both Anonymous. + break :blk .anonymous; + }; + + return switch (mode) { + .no_cors => .{ .request_mode = .no_cors, .credentials_mode = .same_origin }, + .anonymous => .{ .request_mode = .cors, .credentials_mode = .same_origin }, + .use_credentials => .{ .request_mode = .cors, .credentials_mode = .include }, + }; +} + // Returns true when a fetch was started: the link's load/error event fires // when the fetch settles. false (duplicate hint) = no event will fire. // element is null when the hint came from the prescan rather than a . @@ -130,12 +162,16 @@ pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const log.debug(.http, "script queue", .{ .url = owned_url, .ctx = "preload" }); } + const settings = corsSettings(if (element) |e| e.asElement() else null, false); + try frame.makeRequest(.{ .ctx = script, .url = owned_url, .method = .GET, .origin = frame.origin, .resource_type = .script, + .request_mode = settings.request_mode, + .credentials_mode = settings.credentials_mode, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .header_callback = Script.headerCallback, .data_callback = Script.dataCallback, @@ -343,11 +379,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e script.status = pre.status; script.complete = true; } else { + const settings = corsSettings(script_element.asElement(), kind == .module); + const transfer = try self.base.client.newRequest(.{ .url = remote_url, .method = .GET, .origin = frame.origin, .resource_type = .script, + .request_mode = settings.request_mode, + .credentials_mode = settings.credentials_mode, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &frame._http_owner); { @@ -387,12 +427,17 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e const transfer = blk: { errdefer self.base.scriptList(script).remove(&script.node); + + const settings = corsSettings(script_element.asElement(), kind == .module); + const transfer = try frame.newRequest(.{ .ctx = script, .url = remote_url, .method = .GET, .origin = frame.origin, .resource_type = .script, + .request_mode = settings.request_mode, + .credentials_mode = settings.credentials_mode, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .header_callback = Script.headerCallback, .data_callback = Script.dataCallback, From c16a3b3585a6b6f2fe805a9e09551d7cba624a13 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 21 Aug 2026 08:13:55 -0700 Subject: [PATCH 14/41] fix warn log on preflight blocked --- src/network/CorsGate.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 886bf4454..e40204f82 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -62,7 +62,7 @@ fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { transfer.unpark(); if (!allowed) { - log.warn(.http, "blocked by cors (preflight)", .{ .url = transfer.req.url }); + log.warn(.cors, "preflight blocked", .{ .url = transfer.req.url }); transfer.failAsync(error.CorsBlocked); continue; } From f2f93d0bc587a40204a6a2bf20cca2db7f28f4a2 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 21 Aug 2026 08:34:33 -0700 Subject: [PATCH 15/41] fix ordering in Request.InitOpts --- src/browser/webapi/net/Request.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 115fdcffa..88d520a32 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -61,9 +61,9 @@ pub const InitOpts = struct { credentials: Credentials = .@"same-origin", headers: ?Headers.InitOpts = null, method: ?[]const u8 = null, + mode: Mode = .cors, priority: ?[]const u8 = null, redirect: Redirect = .follow, - mode: Mode = .cors, signal: ?*AbortSignal = null, }; From 570a450163bd601572e0faa0d85c4a38dc395356 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 14:42:10 -0700 Subject: [PATCH 16/41] ensure origin is added to requests --- src/browser/frame/resource_load.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/frame/resource_load.zig b/src/browser/frame/resource_load.zig index 941daae25..19a44cbb6 100644 --- a/src/browser/frame/resource_load.zig +++ b/src/browser/frame/resource_load.zig @@ -79,6 +79,7 @@ pub fn image(frame: *Frame, img: *Element.Html.Image, src: []const u8) !void { .ctx = load, .url = resolved, .method = .GET, + .origin = frame.origin, .resource_type = .image, .headers_only = true, .header_callback = ImageLoad.headerCallback, From 89df63e95682dc302c26c593e164c8d705a31e2a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 15:57:19 -0700 Subject: [PATCH 17/41] safelisted methods always pass in CORS --- src/network/CorsGate.zig | 42 ++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index e40204f82..d7a6da9fb 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -73,6 +73,13 @@ fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { } } +fn isSafelistedMethod(value: http.Method) bool { + return switch (value) { + .GET, .HEAD, .POST => true, + else => false, + }; +} + fn isSafelistedContentType(value: []const u8) bool { const semi = std.mem.indexOfScalar(u8, value, ';') orelse value.len; const mime = std.mem.trim(u8, value[0..semi], &std.ascii.whitespace); @@ -97,9 +104,8 @@ fn isSafelistedHeader(name: []const u8, value: []const u8) bool { fn requiresPreflight(transfer: *const Transfer) bool { const req = &transfer.req; - switch (req.method) { - .GET, .HEAD, .POST => {}, - else => return true, + if (!isSafelistedMethod(req.method)) { + return true; } for (transfer.req_headers.items) |hdr| { @@ -211,21 +217,23 @@ const CorsPreflightContext = struct { } } - // Access-Control-Allow-Methods - const allow_methods = acam orelse { - log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acam" }); - return false; - }; + if (!isSafelistedMethod(self.method)) { + // Access-Control-Allow-Methods + const allow_methods = acam orelse { + log.debug(.cors, "preflight blocked", .{ .url = self.url, .reason = "missing acam" }); + return false; + }; - const methods_wildcard = std.mem.eql(u8, allow_methods, "*") and !self.wants_credentials; - if (!methods_wildcard and !methodAllowed(allow_methods, self.method)) { - log.debug(.cors, "preflight blocked", .{ - .url = self.url, - .reason = "method not allowed", - .allow_methods = allow_methods, - .method = @tagName(self.method), - }); - return false; + const methods_wildcard = std.mem.eql(u8, allow_methods, "*") and !self.wants_credentials; + if (!methods_wildcard and !methodAllowed(allow_methods, self.method)) { + log.debug(.cors, "preflight blocked", .{ + .url = self.url, + .reason = "method not allowed", + .allow_methods = allow_methods, + .method = @tagName(self.method), + }); + return false; + } } // Access-Control-Allow-Headers From feebb889adf2ded6c3bdffd8c66a381df0b2d23e Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 15:58:16 -0700 Subject: [PATCH 18/41] cors check before cache check --- src/network/HttpClient.zig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index f999278c3..a7adf5670 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1031,9 +1031,6 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { log.info(.http, "blocked url", .{ .url = transfer.req.url }); return transfer.failAsync(error.UrlBlocked); } - if (try self.cacheLookup(transfer)) { - return; - } if (!isCrossOriginModeAllowed(transfer) and !transfer.req.internal) { log.warn(.http, "blocked by mode", .{ @@ -1052,6 +1049,10 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { continue :sw SubmitFrom.after_cors; }, .after_cors => { + if (try self.cacheLookup(transfer)) { + return; + } + if (self.obey_robots and !transfer.req.internal) { switch (try self.robots.check(transfer)) { .allowed => { From 8ee714418d1d9bfea2666619888e817139ba878c Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 16:48:09 -0700 Subject: [PATCH 19/41] proper safelist checking for CORS headers --- src/network/CorsGate.zig | 71 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index d7a6da9fb..e5165eb24 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -80,6 +80,34 @@ fn isSafelistedMethod(value: http.Method) bool { }; } +fn isCorsUnsafeByte(c: u8) bool { + return switch (c) { + 0...0x08, + 0x0A...0x1F, + '"', + '(', + ')', + ':', + '<', + '>', + '?', + '@', + '[', + '\\', + ']', + '{', + '}', + => true, + 0x7F => true, + else => false, + }; +} + +fn hasNoCorsUnsafeBytes(value: []const u8) bool { + for (value) |c| if (isCorsUnsafeByte(c)) return false; + return true; +} + fn isSafelistedContentType(value: []const u8) bool { const semi = std.mem.indexOfScalar(u8, value, ';') orelse value.len; const mime = std.mem.trim(u8, value[0..semi], &std.ascii.whitespace); @@ -88,16 +116,46 @@ fn isSafelistedContentType(value: []const u8) bool { std.ascii.eqlIgnoreCase(mime, "text/plain"); } +fn isSafelistedLanguageValue(value: []const u8) bool { + for (value) |c| { + const ok = switch (c) { + '0'...'9', + 'A'...'Z', + 'a'...'z', + ' ', + '*', + ',', + '-', + '.', + ';', + '=', + => true, + else => false, + }; + if (!ok) return false; + } + return true; +} + +// https://fetch.spec.whatwg.org/#cors-safelisted-request-header fn isSafelistedHeader(name: []const u8, value: []const u8) bool { - if (std.ascii.eqlIgnoreCase(name, "accept") or - std.ascii.eqlIgnoreCase(name, "accept-language") or + if (value.len > 128) return false; + + if (std.ascii.eqlIgnoreCase(name, "accept")) { + return hasNoCorsUnsafeBytes(value); + } + + if (std.ascii.eqlIgnoreCase(name, "accept-language") or std.ascii.eqlIgnoreCase(name, "content-language")) { - return true; + return isSafelistedLanguageValue(value); } + if (std.ascii.eqlIgnoreCase(name, "content-type")) { - return isSafelistedContentType(value); + return isSafelistedContentType(value) and + hasNoCorsUnsafeBytes(value); } + return false; } @@ -370,7 +428,10 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { for (transfer.req_headers.items) |hdr| { if (hdr.source != .author) continue; if (isSafelistedHeader(hdr.name, hdr.value)) continue; - try header_names.append(arena.allocator(), try arena.dupe(u8, hdr.name)); + try header_names.append( + arena.allocator(), + try std.ascii.allocLowerString(arena.allocator(), hdr.name), + ); } const ctx = try arena.create(CorsPreflightContext); From 2b06583662c91a4abfaae99596a60079b27fc519 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 16:50:33 -0700 Subject: [PATCH 20/41] authorization header doesnt accept wildcard --- src/network/CorsGate.zig | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index e5165eb24..b3f43ff1e 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -302,17 +302,19 @@ const CorsPreflightContext = struct { }; const headers_wildcard = std.mem.eql(u8, allow_headers, "*") and !self.wants_credentials; - if (!headers_wildcard) { - for (self.request_headers) |name| { - if (!headerAllowed(allow_headers, name)) { - log.debug(.cors, "preflight blocked", .{ - .url = self.url, - .reason = "header not allowed", - .allow_headers = allow_headers, - .header = name, - }); - return false; - } + + for (self.request_headers) |name| { + const is_authorization = std.ascii.eqlIgnoreCase(name, "authorization"); + if (headers_wildcard and !is_authorization) continue; + + if (!headerAllowed(allow_headers, name)) { + log.debug(.cors, "preflight blocked", .{ + .url = self.url, + .reason = "header not allowed", + .allow_headers = allow_headers, + .header = name, + }); + return false; } } } From b0fffe693aad073e126d7d895c95004665767713 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 17:12:32 -0700 Subject: [PATCH 21/41] more comprehensive cors singleflight key --- src/network/CorsGate.zig | 79 ++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index b3f43ff1e..910bede73 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -220,10 +220,37 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .pending; } +const CorsKey = struct { + url: []const u8, + origin: []const u8, + method: http.Method, + // lowercased and sorted. + authored_headers: []const []const u8, + + fn build(self: CorsKey, arena: std.mem.Allocator) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + + try buf.appendSlice(arena, self.url); + try buf.append(arena, 0); + try buf.appendSlice(arena, self.origin); + try buf.append(arena, 0); + try buf.appendSlice(arena, @tagName(self.method)); + try buf.append(arena, 0); + + for (self.authored_headers) |h| { + try buf.appendSlice(arena, h); + try buf.append(arena, 0); + } + + return buf.items; + } +}; + const CorsPreflightContext = struct { gate: *CorsGate, arena: *lp.Arena, + key: []const u8, url: [:0]const u8, origin: []const u8, method: http.Method, @@ -396,25 +423,49 @@ const CorsPreflightContext = struct { const gate = self.gate; const arena = self.arena; - gate.single_flight.discard(self.url); + gate.single_flight.discard(self.key); arena.release(); } fn resolve(self: *CorsPreflightContext, allowed: bool) void { const gate = self.gate; const arena = self.arena; - gate.flushPending(self.url, allowed); + gate.flushPending(self.key, allowed); arena.release(); } }; fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { const url = transfer.req.url; + const origin = transfer.req.origin orelse "null"; - const result = try self.single_flight.enter(url, transfer, .cors); + 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, + .authored_headers = header_names.items, + }; + const key = try cors_key.build(transfer.arena.allocator()); + + const result = try self.single_flight.enter(key, transfer, .cors); if (result == .queued) return; errdefer { - self.single_flight.discard(url); + self.single_flight.discard(key); transfer.unpark(); } @@ -425,26 +476,24 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { errdefer arena_pool.release(arena); 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); - 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( - arena.allocator(), - try std.ascii.allocLowerString(arena.allocator(), hdr.name), - ); + const owned_header_names = try arena.alloc([]const u8, header_names.items.len); + for (header_names.items, 0..) |name, i| { + owned_header_names[i] = try arena.dupe(u8, name); } const ctx = try arena.create(CorsPreflightContext); ctx.* = .{ .gate = self, .arena = arena, - .url = owned_url, - .origin = try arena.dupe(u8, transfer.req.origin orelse "null"), + .key = owned_key, + .url = owned_url, + .origin = owned_origin, .method = transfer.req.method, - .request_headers = header_names.items, + .request_headers = owned_header_names, .wants_credentials = transfer.req.credentials_mode == .include, }; From d1f4605459ee388bb0868a1aecb7b2b68f6c0a8a Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 17:25:59 -0700 Subject: [PATCH 22/41] non-default credentials and request mode --- src/browser/Frame.zig | 4 ++++ src/browser/ScriptManagerBase.zig | 6 ++++++ src/browser/frame/resource_load.zig | 2 ++ src/browser/webapi/SharedWorkerGlobalScope.zig | 2 ++ src/browser/webapi/Worker.zig | 2 ++ src/browser/webapi/WorkerGlobalScope.zig | 2 ++ src/browser/webapi/net/EventSource.zig | 2 ++ src/browser/webapi/net/XMLHttpRequest.zig | 1 + src/network/CorsGate.zig | 2 ++ src/network/HttpClient.zig | 4 ++-- src/network/RobotsGate.zig | 2 ++ 11 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index b916acb33..8939cee6c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -859,6 +859,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo .throttle = self.parent == null, .origin = self.origin, .resource_type = .document, + .request_mode = .navigate, + .credentials_mode = .include, .header_callback = frameHeaderDoneCallback, .data_callback = frameDataCallback, .done_callback = frameDoneCallback, @@ -2383,6 +2385,8 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co .url = resolved, .method = .GET, .origin = self.origin, + .request_mode = .no_cors, + .credentials_mode = .same_origin, .resource_type = .stylesheet, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &self._http_owner) catch |err| { diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 1f6a8ce97..bc8e34e35 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -249,6 +249,9 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co .ctx = script, .url = url, .method = .GET, + .origin = owner.origin(), + .request_mode = .cors, + .credentials_mode = .same_origin, .resource_type = .script, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .header_callback = Script.headerCallback, @@ -438,6 +441,9 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy .url = url, .method = .GET, .resource_type = .script, + .origin = owner.origin(), + .request_mode = .cors, + .credentials_mode = .same_origin, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .header_callback = Script.headerCallback, .data_callback = Script.dataCallback, diff --git a/src/browser/frame/resource_load.zig b/src/browser/frame/resource_load.zig index 19a44cbb6..ddf60f861 100644 --- a/src/browser/frame/resource_load.zig +++ b/src/browser/frame/resource_load.zig @@ -80,6 +80,8 @@ pub fn image(frame: *Frame, img: *Element.Html.Image, src: []const u8) !void { .url = resolved, .method = .GET, .origin = frame.origin, + .request_mode = .no_cors, + .credentials_mode = .same_origin, .resource_type = .image, .headers_only = true, .header_callback = ImageLoad.headerCallback, diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 1e640c9b5..b7f3b9cbb 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -106,6 +106,8 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor .url = owned_url, .resource_type = .worker, .origin = frame.origin, + .credentials_mode = .same_origin, + .request_mode = .same_origin, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index e7c0e3421..a1c5cd22f 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -107,6 +107,8 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker { .loader_id = self._loader_id, .resource_type = if (self._type == .module) .script else .worker, .origin = frame.origin, + .request_mode = .same_origin, + .credentials_mode = .same_origin, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, .done_callback = httpDoneCallback, diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index d2199aa40..1cd27190d 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -431,6 +431,8 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! .method = .GET, .resource_type = .worker, .origin = self.origin, + .request_mode = .same_origin, + .credentials_mode = .same_origin, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &self._http_owner) catch |err| { log.warn(.http, "importScript", .{ .url = resolved_url, .err = err }); diff --git a/src/browser/webapi/net/EventSource.zig b/src/browser/webapi/net/EventSource.zig index a3ac7319b..61737d4a4 100644 --- a/src/browser/webapi/net/EventSource.zig +++ b/src/browser/webapi/net/EventSource.zig @@ -180,6 +180,8 @@ fn connect(self: *EventSource) !void { .method = .GET, .cookies = cookie_support, .origin = exec.origin(), + .request_mode = .cors, + .credentials_mode = if (self._with_credentials) .include else .same_origin, .resource_type = .eventsource, .streaming = true, .header_callback = httpHeaderDoneCallback, diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index df65cf72e..07e2e473b 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -317,6 +317,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v .body = self._request_body, .cookies = cookie_support, .credentials_mode = if (self._with_credentials) .include else .same_origin, + .request_mode = .cors, .origin = exec.origin(), .resource_type = .xhr, .timeout_ms = self._timeout, diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 910bede73..31c4b65cd 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -509,6 +509,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { .cookie_jar = null, .cookie_origin = transfer.req.cookie_origin, .origin = transfer.req.origin, + .credentials_mode = .omit, + .request_mode = .no_cors, .ctx = ctx, .header_callback = CorsPreflightContext.headerCallback, .done_callback = CorsPreflightContext.doneCallback, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index a7adf5670..4128238ed 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1850,8 +1850,8 @@ pub const Request = struct { redirect: RedirectMode = .follow, referrer_policy: ?referrer.Policy = null, basic_auth_credentials: ?[:0]const u8 = null, - credentials_mode: CredentialsMode = .same_origin, - request_mode: RequestMode = .cors, + credentials_mode: CredentialsMode, + request_mode: RequestMode, timeout_ms: u32 = 0, skip_cache: bool = false, diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index 3ff2e1e23..49ba8113c 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -117,6 +117,8 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans .loader_id = transfer.req.loader_id, .notification = transfer.req.notification, .origin = null, + .credentials_mode = .omit, + .request_mode = .no_cors, .ctx = robots_ctx, .header_callback = RobotsContext.headerCallback, .data_callback = RobotsContext.dataCallback, From 12ed38dfdda811ba69767a41239f58fb883c3063 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 17:36:45 -0700 Subject: [PATCH 23/41] better no cors opaque behavior --- src/browser/webapi/net/Fetch.zig | 2 +- src/network/CorsGate.zig | 4 ++-- src/network/HttpClient.zig | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 74f50bac3..f1212e3fa 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -178,7 +178,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { // no-cors mode: regardless of what the server returned, JS only ever sees // an opaque response — status 0, no headers, no body, url "". - if (self._no_cors) { + if (self._no_cors and transfer.client.obey_cors and transfer._cors_cross_origin) { res._status = 0; res._status_text = ""; res._url = ""; diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 31c4b65cd..bff3060cc 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -190,6 +190,8 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const origin = req.origin orelse "null"; try transfer.setHeader(ORIGIN, origin, .{}); + transfer._cors_cross_origin = true; + if (req.request_mode == .no_cors) { log.debug(.cors, "cross origin", .{ .url = req.url, @@ -199,8 +201,6 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { return .allowed; } - transfer._cors_cross_origin = true; - if (!requiresPreflight(transfer)) { log.debug(.cors, "cross origin", .{ .url = req.url, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 4128238ed..24b07e335 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1706,7 +1706,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T try transfer.materializeResponse(msg.conn, .{}); // Validate the headers for the response with CORS. - if (transfer._cors_cross_origin) { + if (transfer._cors_cross_origin and transfer.req.request_mode == .cors) { CorsGate.validateResponse(transfer) catch |err| { self.removeConn(msg.conn); transfer._conn = null; From 1fe8456cbde1dec668aec66e1378f48f7f7ca2be Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 25 Aug 2026 19:49:29 -0700 Subject: [PATCH 24/41] add modes to the tests --- src/network/HttpClient.zig | 32 ++++++++++++++++++++++-------- src/network/SingleFlight.zig | 2 ++ src/network/WebBotAuth.zig | 2 ++ src/server/cdp/CDP.zig | 2 ++ src/server/cdp/domains/network.zig | 8 ++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 24b07e335..701ee075f 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -4228,7 +4228,8 @@ fn testTransfer(arena: *lp.Arena) Transfer { .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, }, @@ -4457,7 +4458,8 @@ test "HttpClient: fulfillIntercepted survives a done_callback that tears down th .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4538,6 +4540,8 @@ test "HttpClient: kill during done_callback does not also fire shutdown_callback .req = .{ .method = .GET, .url = "http://example.com/", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .xhr, .shutdown_callback = Ctx.shutdownCallback, .ctx = &ctx, @@ -4619,6 +4623,8 @@ test "HttpClient: kill during a non-terminal callback defers shutdown_callback" .req = .{ .method = .GET, .url = "http://example.com/", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .xhr, .shutdown_callback = Ctx.shutdownCallback, .ctx = &ctx, @@ -4675,7 +4681,8 @@ test "HttpClient: aborting a robots-parked transfer unlinks it from the gate" { .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, }, @@ -4739,7 +4746,8 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" { .method = .POST, .url = "http://example.com/start", .body = "payload", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, @@ -4779,7 +4787,8 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" { .method = .POST, .url = "http://example.com/start", .body = "payload", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, @@ -4843,7 +4852,8 @@ test "HttpClient: fulfillIntercepted delivers a 3xx without a Location as the re .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4908,6 +4918,8 @@ test "HttpClient: abortParked survives an error_callback that tears down the own .method = .GET, .url = "http://example.com/", .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4981,7 +4993,8 @@ test "HttpClient: abort survives an error_callback that tears down the owner" { .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .xhr, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -5014,7 +5027,8 @@ test "HttpClient: abort survives an error_callback that tears down the owner" { .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .xhr, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -5074,6 +5088,8 @@ test "HttpClient: throttled navigations wait for their per-host slot" { .req = .{ .method = .GET, .url = url, + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, diff --git a/src/network/SingleFlight.zig b/src/network/SingleFlight.zig index 76deabb30..d6befa9ec 100644 --- a/src/network/SingleFlight.zig +++ b/src/network/SingleFlight.zig @@ -106,6 +106,8 @@ fn makeTestTransfer(arena: *lp.Arena, client: *HttpClient, id: u32) !*Transfer { .req = .{ .method = .GET, .url = "http://example.com/", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = HttpClient.noopShutdown, }, diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 9fc80cc89..8114efdb3 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -244,6 +244,8 @@ test "signRequest: adds headers with correct names" { .req = .{ .method = .GET, .url = "https://example.com/", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = @import("HttpClient.zig").noopShutdown, }, diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index e9c431b65..03759c378 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -1516,6 +1516,8 @@ test "cdp: syncRequest short-circuits after disconnect" { const transfer = try client.newRequest(.{ .method = .GET, .url = "http://127.0.0.1:9582/", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .fetch, .shutdown_callback = HttpClient.noopShutdown, }, null); diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 12a43bc3d..c98782e76 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -1177,6 +1177,8 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" { .loader_id = 1, .method = .GET, .url = "https://blocked.test/script.js", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .script, .notification = bc.session.notification, .ctx = &error_context, @@ -1201,6 +1203,8 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" { .loader_id = 1, .method = .GET, .url = "http://127.0.0.1:9582/redirect-no-fragment", + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .script, .notification = bc.session.notification, .ctx = &error_context, @@ -1240,6 +1244,8 @@ test "cdp.Network: POST body exposed as postData" { .method = .POST, .url = "http://127.0.0.1:9582/echo_body", .body = body, + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .fetch, .notification = bc.session.notification, .shutdown_callback = HttpClient.noopShutdown, @@ -1501,6 +1507,8 @@ test "cdp.Network: redirect hop precedes Fetch pause and carries redirectRespons .loader_id = 7, .method = .GET, .url = start_url, + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .script, .notification = bc.session.notification, .ctx = &callback_context, From 15d27902ba1f6b49b333eedb3e00f125b0bb317f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 26 Aug 2026 08:31:06 -0700 Subject: [PATCH 25/41] use experimental features flag instead of obey cors --- src/Config.zig | 10 +++++++--- src/help.zon | 11 ++++++++--- src/network/HttpClient.zig | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index c9f215b7a..c5d1fa360 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -244,10 +244,13 @@ pub const LoadResources = packed struct(u4) { stylesheet: bool = false, }; +pub const ExperimentalFeatures = packed struct(u1) { + cors: bool = false, +}; + /// Common CLI args. const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, - .{ .name = "obey_cors", .type = bool }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, @@ -281,6 +284,7 @@ const CommonOptions = .{ .{ .name = "disable_subframes", .type = bool, .deprecated = "subframes are now disabled by default, use \"--load-resources iframe\" to enable" }, .{ .name = "disable_workers", .type = bool, .deprecated = "workers are now disabled by default, use \"--load-resources worker\" to enable" }, .{ .name = "enable_external_stylesheets", .type = bool, .deprecated = "use \"--load-resources stylesheet\" to enable" }, + .{ .name = "experimental_features", .type = ExperimentalFeatures, .default = ExperimentalFeatures{} }, .{ .name = "load_resources", .type = LoadResources, .default = LoadResources{} }, .{ .name = "v8_flags_unsafe", .type = ?[]const u8 }, .{ .name = "v8_max_heap_mb", .type = ?u32 }, @@ -556,9 +560,9 @@ pub fn httpVersion(self: *const Config) HttpVersion { }; } -pub fn obeyCors(self: *const Config) bool { +pub fn experimentalFeatures(self: *const Config) ExperimentalFeatures { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.obey_cors, + inline .serve, .fetch, .mcp, .agent => |opts| opts.experimental_features, else => unreachable, }; } diff --git a/src/help.zon b/src/help.zon index 3451600e4..90308e9b6 100644 --- a/src/help.zon +++ b/src/help.zon @@ -359,6 +359,14 @@ \\ --cookie-jar \\ Path to a JSON file to save cookies to on exit (write-only). \\ Defaults to no cookie saving. + \\ --experimental-features + \\ Enable an experimental, unstable feature. Can be passed multiple times. + \\ Behavior may change or be removed without notice. + \\ Defaults to none enabled. + \\ Allowed values: + \\ cors Obey CORS (cross-origin resource sharing) checks + \\ on fetch/XHR requests instead of allowing them + \\ unconditionally. \\ --load-resources \\ Sub-resource to actually request. Can be passed multiple times. \\ Defaults to requesting none of them. @@ -453,9 +461,6 @@ \\ --obey-robots \\ Fetches and obeys robots.txt of the target page. \\ Defaults to false. - \\ --obey-cors - \\ Obeys CORS rules, executing preflights as needed. - \\ Defaults to false. \\ --proxy-bearer-token \\ Token sent for bearer authentication with the proxy: \\ Proxy-Authorization: Bearer . diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 701ee075f..ba367ca56 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -232,7 +232,7 @@ pub fn init(self: *Client, app: *lp.App) !void { .serve_mode = config.mode == .serve, .obey_robots = config.obeyRobots(), .http_version = config.httpVersion(), - .obey_cors = config.obeyCors(), + .obey_cors = config.experimentalFeatures().cors, .robots = .{ .network = network, .single_flight = .init(allocator), From addba12426b0d3c81406eedb09e3ea3e260bd9ec Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 28 Aug 2026 08:45:10 -0700 Subject: [PATCH 26/41] add CORS metrics --- src/Metrics.zig | 6 ++++++ src/network/CorsGate.zig | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Metrics.zig b/src/Metrics.zig index 89a351495..0743d3a9c 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -91,6 +91,9 @@ http_navigation_delay_ms: Histogram(&.{ }) = .{}, robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, robots_access: CounterEnum("result", enum { allow, deny }) = .{}, +cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight }) = .{}, +cors_preflight: CounterEnum("result", enum { allowed, blocked }) = .{}, +cors_response: CounterEnum("result", enum { allowed, blocked }) = .{}, // Emitted as each metric's "# HELP" line. A field without an entry is a // compile error. @@ -123,6 +126,9 @@ const help = .{ .http_navigation_delay_ms = "Time in milliseconds a throttled top-level navigation waited", .robots_status = "robots.txt response status", .robots_access = "robots.txt result", + .cors_check = "CORS initial classification: same_origin/no_cors need no CORS handling, simple needs response validation only, preflight needs an OPTIONS round-trip first", + .cors_preflight = "CORS preflight (OPTIONS) results, one per request that required one", + .cors_response = "CORS actual-response validation results", }; pub fn write(self: *const Metrics, writer: *std.Io.Writer) void { diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index bff3060cc..06d519d2a 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -62,11 +62,13 @@ fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { transfer.unpark(); if (!allowed) { + lp.metrics.cors_preflight.incr(.blocked); log.warn(.cors, "preflight blocked", .{ .url = transfer.req.url }); transfer.failAsync(error.CorsBlocked); continue; } + lp.metrics.cors_preflight.incr(.allowed); transfer.client.resumeAfterCors(transfer) catch |e| { transfer.abortPipelineError(e); }; @@ -183,6 +185,7 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { if (req.origin) |origin| { if (URL.isSameOrigin(req.url, origin)) { log.debug(.cors, "same origin", .{ .url = req.url, .origin = origin }); + lp.metrics.cors_check.incr(.same_origin); return .allowed; } } @@ -198,6 +201,7 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { .origin = origin, .mode = "no-cors", }); + lp.metrics.cors_check.incr(.no_cors); return .allowed; } @@ -207,6 +211,7 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { .origin = origin, .preflight = false, }); + lp.metrics.cors_check.incr(.simple); return .allowed; } @@ -215,6 +220,7 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { .origin = origin, .preflight = true, }); + lp.metrics.cors_check.incr(.preflight); try self.fetchThenResume(transfer); return .pending; @@ -548,6 +554,8 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { pub fn validateResponse(transfer: *Transfer) !void { const req = &transfer.req; + errdefer lp.metrics.cors_response.incr(.blocked); + const allow_origin = HttpClient.findHeader(transfer.res.headers, ACCESS_CONTROL_ALLOW_ORIGIN) orelse { log.warn(.cors, "blocked", .{ .url = req.url, .reason = "missing acao" }); return error.CorsBlocked; @@ -589,4 +597,6 @@ pub fn validateResponse(transfer: *Transfer) !void { return error.CorsBlocked; } } + + lp.metrics.cors_response.incr(.allowed); } From c3a71efca41de3fe4a1242aa8b6cef27490fb18d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 31 Aug 2026 07:08:39 -0700 Subject: [PATCH 27/41] include modes on EchoDriver --- src/server/cdp/domains/network.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index c98782e76..1256fdfb5 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -1306,6 +1306,9 @@ const EchoDriver = struct { .method = .POST, .url = "http://127.0.0.1:9582/echo_body", .body = body, + .origin = bc.security_origin, + .request_mode = .no_cors, + .credentials_mode = .same_origin, .resource_type = .fetch, .notification = bc.session.notification, .ctx = &driver, From e195886d4359a9cd181ee332ad032d0385021586 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 31 Aug 2026 07:08:50 -0700 Subject: [PATCH 28/41] use same cors guard for fetch body as headers --- src/browser/webapi/net/Fetch.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index f1212e3fa..c4046269e 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -235,7 +235,7 @@ fn httpDataCallback(transfer: *Transfer, data: []const u8) !void { } } - if (self._no_cors) { + if (self._no_cors and transfer.client.obey_cors and transfer._cors_cross_origin) { return; } From 6002dfe587ebf55c410329519eef181376812e58 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 31 Aug 2026 07:12:46 -0700 Subject: [PATCH 29/41] use no_cors for importScript --- src/browser/webapi/WorkerGlobalScope.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 1cd27190d..8bcf7f25b 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -431,7 +431,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! .method = .GET, .resource_type = .worker, .origin = self.origin, - .request_mode = .same_origin, + .request_mode = .no_cors, .credentials_mode = .same_origin, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own }, &self._http_owner) catch |err| { From 8a8bb3814fce248049393ed72eaa1b2ed9cc6ec6 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 1 Sep 2026 23:10:04 -0700 Subject: [PATCH 30/41] fix test running --- src/browser/ScriptManagerBase.zig | 6 ++++++ src/network/CorsGate.zig | 2 -- src/network/HttpClient.zig | 13 ++++++++++++- src/network/SingleFlight.zig | 1 + src/network/WebBotAuth.zig | 1 + src/server/cdp/CDP.zig | 1 + src/server/cdp/domains/network.zig | 4 ++++ 7 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index bc8e34e35..af05602cd 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -55,6 +55,12 @@ pub const Owner = union(enum) { }; } + pub fn origin(self: Owner) ?[]const u8 { + return switch (self) { + inline else => |g| g.origin, + }; + } + pub fn jsContext(self: Owner) *js.Context { return switch (self) { inline else => |g| g.js, diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 06d519d2a..f268ab91e 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -512,8 +512,6 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { .document_frame_id = transfer.req.document_frame_id, .loader_id = transfer.req.loader_id, .notification = transfer.req.notification, - .cookie_jar = null, - .cookie_origin = transfer.req.cookie_origin, .origin = transfer.req.origin, .credentials_mode = .omit, .request_mode = .no_cors, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index ba367ca56..923a078b8 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -4228,6 +4228,7 @@ fn testTransfer(arena: *lp.Arena) Transfer { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, @@ -4458,6 +4459,7 @@ test "HttpClient: fulfillIntercepted survives a done_callback that tears down th .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, @@ -4540,6 +4542,7 @@ test "HttpClient: kill during done_callback does not also fire shutdown_callback .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .xhr, @@ -4623,6 +4626,7 @@ test "HttpClient: kill during a non-terminal callback defers shutdown_callback" .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .xhr, @@ -4681,6 +4685,7 @@ test "HttpClient: aborting a robots-parked transfer unlinks it from the gate" { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, @@ -4745,6 +4750,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" { .req = .{ .method = .POST, .url = "http://example.com/start", + .origin = null, .body = "payload", .credentials_mode = .omit, .request_mode = .no_cors, @@ -4786,6 +4792,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" { .req = .{ .method = .POST, .url = "http://example.com/start", + .origin = null, .body = "payload", .credentials_mode = .omit, .request_mode = .no_cors, @@ -4852,6 +4859,7 @@ test "HttpClient: fulfillIntercepted delivers a 3xx without a Location as the re .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, @@ -4917,7 +4925,7 @@ test "HttpClient: abortParked survives an error_callback that tears down the own .req = .{ .method = .GET, .url = "http://example.com/", - .origin = "", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, @@ -4993,6 +5001,7 @@ test "HttpClient: abort survives an error_callback that tears down the owner" { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .xhr, @@ -5027,6 +5036,7 @@ test "HttpClient: abort survives an error_callback that tears down the owner" { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .xhr, @@ -5088,6 +5098,7 @@ test "HttpClient: throttled navigations wait for their per-host slot" { .req = .{ .method = .GET, .url = url, + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, diff --git a/src/network/SingleFlight.zig b/src/network/SingleFlight.zig index d6befa9ec..6952c040d 100644 --- a/src/network/SingleFlight.zig +++ b/src/network/SingleFlight.zig @@ -106,6 +106,7 @@ fn makeTestTransfer(arena: *lp.Arena, client: *HttpClient, id: u32) !*Transfer { .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 8114efdb3..79e2245a0 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -244,6 +244,7 @@ test "signRequest: adds headers with correct names" { .req = .{ .method = .GET, .url = "https://example.com/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .document, diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index 03759c378..6960946b6 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -1516,6 +1516,7 @@ test "cdp: syncRequest short-circuits after disconnect" { const transfer = try client.newRequest(.{ .method = .GET, .url = "http://127.0.0.1:9582/", + .origin = null, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .fetch, diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 1256fdfb5..beee2fc9c 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -1177,6 +1177,7 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" { .loader_id = 1, .method = .GET, .url = "https://blocked.test/script.js", + .origin = bc.security_origin, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .script, @@ -1203,6 +1204,7 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" { .loader_id = 1, .method = .GET, .url = "http://127.0.0.1:9582/redirect-no-fragment", + .origin = bc.security_origin, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .script, @@ -1243,6 +1245,7 @@ test "cdp.Network: POST body exposed as postData" { .loader_id = 1, .method = .POST, .url = "http://127.0.0.1:9582/echo_body", + .origin = bc.security_origin, .body = body, .credentials_mode = .omit, .request_mode = .no_cors, @@ -1510,6 +1513,7 @@ test "cdp.Network: redirect hop precedes Fetch pause and carries redirectRespons .loader_id = 7, .method = .GET, .url = start_url, + .origin = bc.security_origin, .credentials_mode = .omit, .request_mode = .no_cors, .resource_type = .script, From 827d0f059838057813eeb7b01e6461261203d338 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 2 Sep 2026 06:21:01 -0700 Subject: [PATCH 31/41] only check cross origin mode on obey cors --- src/network/HttpClient.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 923a078b8..6b02e7b0a 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1032,7 +1032,7 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { return transfer.failAsync(error.UrlBlocked); } - if (!isCrossOriginModeAllowed(transfer) and !transfer.req.internal) { + if (self.obey_cors and !isCrossOriginModeAllowed(transfer) and !transfer.req.internal) { log.warn(.http, "blocked by mode", .{ .url = transfer.req.url, .mode = @tagName(transfer.req.request_mode), From db9ccfb7c90d0a048d426bcb4e1af26a4b436e14 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 2 Sep 2026 06:25:53 -0700 Subject: [PATCH 32/41] fetch can't be navigate mode --- src/browser/webapi/net/Fetch.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index c4046269e..03c08a6f1 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -57,6 +57,12 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis resolver.rejectError("fetch init error", .{ .type_error = "Failed to construct Request" }); return resolver.promise(); }; + + if (request._mode == .navigate) { + resolver.rejectError("fetch request mode error", .{ .type_error = "Fetch can't be navigate" }); + return resolver.promise(); + } + // This Request is never exposed to JS. makeRequest dupes the url/body // into the transfer, so nothing references it once we return. request.acquireRef(); @@ -109,7 +115,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .cors => .cors, .@"no-cors" => .no_cors, .@"same-origin" => .same_origin, - .navigate => .navigate, + .navigate => @panic("fetch can't be navigate mode"), }, .origin = exec.origin(), .redirect = switch (request._redirect) { From 868882b0c8b60c290d5eadf73703fb506e1aeb84 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 2 Sep 2026 06:31:51 -0700 Subject: [PATCH 33/41] add origin conditionally on no_cors --- src/network/CorsGate.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index f268ab91e..50f1139b0 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -191,10 +191,17 @@ pub fn check(self: *CorsGate, transfer: *Transfer) !Result { } const origin = req.origin orelse "null"; - try transfer.setHeader(ORIGIN, origin, .{}); transfer._cors_cross_origin = true; + // https://fetch.spec.whatwg.org/#append-a-request-origin-header + // + // If the request is no cors, we only add the origin if it is not HEAD or GET. + // TODO: Should use referrer policy. + if (req.request_mode != .no_cors or (req.method != .HEAD and req.method != .GET)) { + try transfer.setHeader("Origin", origin, .{}); + } + if (req.request_mode == .no_cors) { log.debug(.cors, "cross origin", .{ .url = req.url, From 1f9342e92fb74776634b499f236fe1a4461fb4ff Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Wed, 2 Sep 2026 06:34:20 -0700 Subject: [PATCH 34/41] add credentials to preflight key for cors --- src/network/CorsGate.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index 50f1139b0..f76185d47 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -237,6 +237,7 @@ const CorsKey = struct { url: []const u8, origin: []const u8, method: http.Method, + wants_credentials: bool, // lowercased and sorted. authored_headers: []const []const u8, @@ -249,6 +250,8 @@ const CorsKey = struct { try buf.append(arena, 0); try buf.appendSlice(arena, @tagName(self.method)); try buf.append(arena, 0); + try buf.append(arena, if (self.wants_credentials) 1 else 0); + try buf.append(arena, 0); for (self.authored_headers) |h| { try buf.appendSlice(arena, h); @@ -471,6 +474,7 @@ fn fetchThenResume(self: *CorsGate, transfer: *Transfer) !void { .url = url, .origin = origin, .method = transfer.req.method, + .wants_credentials = transfer.req.credentials_mode == .include, .authored_headers = header_names.items, }; const key = try cors_key.build(transfer.arena.allocator()); From 23c1c1018fd983aba3ba83b6dce64d2310317c3d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 3 Sep 2026 09:31:44 -0700 Subject: [PATCH 35/41] properly use include on default corsSettings and image load --- src/browser/ScriptManager.zig | 2 +- src/browser/frame/resource_load.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index c9c8650a9..10410d967 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -123,7 +123,7 @@ fn corsSettings(element: ?*Element, is_module: bool) CorsSettings { }; return switch (mode) { - .no_cors => .{ .request_mode = .no_cors, .credentials_mode = .same_origin }, + .no_cors => .{ .request_mode = .no_cors, .credentials_mode = .include }, .anonymous => .{ .request_mode = .cors, .credentials_mode = .same_origin }, .use_credentials => .{ .request_mode = .cors, .credentials_mode = .include }, }; diff --git a/src/browser/frame/resource_load.zig b/src/browser/frame/resource_load.zig index ddf60f861..b66febc2a 100644 --- a/src/browser/frame/resource_load.zig +++ b/src/browser/frame/resource_load.zig @@ -81,7 +81,7 @@ pub fn image(frame: *Frame, img: *Element.Html.Image, src: []const u8) !void { .method = .GET, .origin = frame.origin, .request_mode = .no_cors, - .credentials_mode = .same_origin, + .credentials_mode = .include, .resource_type = .image, .headers_only = true, .header_callback = ImageLoad.headerCallback, From 0aeba826b49fc9bd094b7a25f49c51943e87c667 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 3 Sep 2026 09:39:25 -0700 Subject: [PATCH 36/41] use credentials_mode instead of cookie bool --- src/browser/webapi/net/EventSource.zig | 2 - src/browser/webapi/net/Fetch.zig | 5 --- src/browser/webapi/net/XMLHttpRequest.zig | 7 +--- src/network/HttpClient.zig | 47 +++++++++++++++-------- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/browser/webapi/net/EventSource.zig b/src/browser/webapi/net/EventSource.zig index 61737d4a4..8a7424a8a 100644 --- a/src/browser/webapi/net/EventSource.zig +++ b/src/browser/webapi/net/EventSource.zig @@ -172,13 +172,11 @@ fn connect(self: *EventSource) !void { try self._id_buf.appendSlice(self._arena.allocator(), self._last_event_id.items); const same_origin = exec.isSameOrigin(self._url); - const cookie_support = self._with_credentials or same_origin; const transfer = try exec.newRequest(.{ .ctx = self, .url = self._url, .method = .GET, - .cookies = cookie_support, .origin = exec.origin(), .request_mode = .cors, .credentials_mode = if (self._with_credentials) .include else .same_origin, diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 03c08a6f1..9de5585da 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -101,11 +101,6 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .method = request._method, .body = request._body, .resource_type = .fetch, - .cookies = switch (request._credentials) { - .omit => false, - .include => true, - .@"same-origin" => exec.isSameOrigin(request._url), - }, .credentials_mode = switch (request._credentials) { .omit => .omit, .@"same-origin" => .same_origin, diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 07e2e473b..bdd616d07 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -303,9 +303,6 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v const exec = self._exec; - // Only add cookies for same-origin or when withCredentials is true - const cookie_support = self._with_credentials or exec.isSameOrigin(self._url); - self.acquireRef(); self._active_requests += 1; self._send_flag = true; @@ -315,7 +312,6 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v .url = self._url, .method = self._method, .body = self._request_body, - .cookies = cookie_support, .credentials_mode = if (self._with_credentials) .include else .same_origin, .request_mode = .cors, .origin = exec.origin(), @@ -340,7 +336,8 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v self._send_flag = false; } try self._request_headers.populateRequestHeaders(transfer); - if (cookie_support) { + + if (transfer.req.credentialsAllowed()) { try exec.headersForRequest(transfer); } } diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 6b02e7b0a..079d6749b 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -633,7 +633,7 @@ pub fn newRequest(self: *Client, req: Request, owner: ?*Owner) anyerror!*Transfe if (owned.loader_id == 0) owned.loader_id = o.loader_id; if (owned.document_frame_id == null) owned.document_frame_id = o.document_frame_id; if (owned.notification == null) owned.notification = o.notification; - if (req.cookies) cookie_jar = o.cookie_jar; + cookie_jar = o.cookie_jar; } // Resolved onto the transfer; the request's copy is left null so // nothing reads the caller's (possibly short-lived) url through it. @@ -1872,10 +1872,6 @@ pub const Request = struct { document_frame_id: ?u32 = null, notification: ?*Notification = null, - // Send the owner's cookies and honour Set-Cookie. Off for a credential-less - // fetch / XHR / EventSource. Meaningless without an owner: there is no jar. - cookies: bool = true, - // The site for SameSite checks. null = the owner's (Owner.siteForCookies). // Frame.navigate is the one caller with a reason to override it: the // initiator of a top-level navigation isn't the frame being navigated. @@ -1919,6 +1915,17 @@ pub const Request = struct { // every caller decides — pass `HttpClient.noopShutdown` to opt out, // knowingly. shutdown_callback: ShutdownCallback, + + pub fn credentialsAllowed(req: *const Request) bool { + return switch (req.credentials_mode) { + .omit => false, + .include => true, + .same_origin => blk: { + const origin = req.origin orelse break :blk false; + break :blk URL.isSameOrigin(req.url, origin); + }, + }; + } }; pub const SyncResponse = struct { @@ -2012,10 +2019,12 @@ fn fulfillRedirect( errdefer |err| transfer.abortPipelineError(err); // retrieve cookies from the fulfilled response's headers. - if (transfer.cookie_jar) |jar| { - for (headers) |hdr| { - if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) { - try jar.populateFromResponse(transfer.req.url, hdr.value); + if (transfer.req.credentialsAllowed()) { + if (transfer.cookie_jar) |jar| { + for (headers) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) { + try jar.populateFromResponse(transfer.req.url, hdr.value); + } } } } @@ -2583,8 +2592,10 @@ pub const Transfer = struct { } pub fn getCookieString(self: *Transfer, arena: Allocator) !?[:0]const u8 { - const jar = self.cookie_jar orelse return null; const req = &self.req; + if (!req.credentialsAllowed()) return null; + + const jar = self.cookie_jar orelse return null; var aw: std.Io.Writer.Allocating = .init(arena); try jar.forRequest(req.url, &aw.writer, .{ .is_http = true, @@ -3055,13 +3066,15 @@ pub const Transfer = struct { const headers = try it.collect(arena.allocator()); self.res.headers = headers.items; - if (self.cookie_jar) |jar| { - for (self.res.headers) |hdr| { - if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) { - jar.populateFromResponse(self.req.url, hdr.value) catch |err| { - log.err(.http, "set cookie", .{ .err = err, .req = self }); - return err; - }; + if (self.req.credentialsAllowed()) { + if (self.cookie_jar) |jar| { + for (self.res.headers) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "set-cookie")) { + jar.populateFromResponse(self.req.url, hdr.value) catch |err| { + log.err(.http, "set cookie", .{ .err = err, .req = self }); + return err; + }; + } } } } From 0610d5ecd180dccdfe32dd260e9807e65fe5ec94 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 3 Sep 2026 09:55:45 -0700 Subject: [PATCH 37/41] collapse isCrossOriginModeAllowed check in pipeline --- src/network/HttpClient.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 079d6749b..709cc2206 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1032,15 +1032,15 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { return transfer.failAsync(error.UrlBlocked); } - if (self.obey_cors and !isCrossOriginModeAllowed(transfer) and !transfer.req.internal) { - log.warn(.http, "blocked by mode", .{ - .url = transfer.req.url, - .mode = @tagName(transfer.req.request_mode), - }); - return transfer.failAsync(error.ModeBlocked); - } - if (self.obey_cors and !transfer.req.internal) { + if (!isCrossOriginModeAllowed(transfer)) { + log.warn(.http, "blocked by mode", .{ + .url = transfer.req.url, + .mode = @tagName(transfer.req.request_mode), + }); + return transfer.failAsync(error.ModeBlocked); + } + switch (try self.cors.check(transfer)) { .allowed => {}, .pending => return, From 54518383c0a73ac69ab1b975dcbcd91168ff25b5 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 3 Sep 2026 10:07:24 -0700 Subject: [PATCH 38/41] enforce cors response on redirects as well --- src/network/HttpClient.zig | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 709cc2206..c2c1510eb 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1565,6 +1565,19 @@ fn processMessages(self: *Client) !bool { return processed; } +fn enforceCorsResponse(self: *Client, msg: http.Handles.MultiMessage, transfer: *Transfer) bool { + if (!(transfer._cors_cross_origin and transfer.req.request_mode == .cors)) { + return false; + } + CorsGate.validateResponse(transfer) catch |err| { + self.removeConn(msg.conn); + transfer._conn = null; + transfer.failAsync(err); + return true; + }; + return false; +} + fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *Transfer) !bool { // Workaround for libcurl Brotli trailing-byte rejection. // @@ -1660,6 +1673,8 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T // requestWillBeSent event has been serialized. Will be // reset() in makeRequest. try transfer.materializeResponse(msg.conn, .{ .check_content_length = false }); + if (self.enforceCorsResponse(msg, transfer)) return true; + try transfer.handleRedirect(location.value); if (!transfer.req.internal) { @@ -1704,16 +1719,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T } try transfer.materializeResponse(msg.conn, .{}); - - // Validate the headers for the response with CORS. - if (transfer._cors_cross_origin and transfer.req.request_mode == .cors) { - CorsGate.validateResponse(transfer) catch |err| { - self.removeConn(msg.conn); - transfer._conn = null; - transfer.failAsync(err); - return true; - }; - } + if (self.enforceCorsResponse(msg, transfer)) return true; // Latency is only meaningful for responses that hit the network (cache // and synthetic responses never reach processOneMessage). From 34b743fbd6fcb6a3f1e92607f3d9e635d99aa1db Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 3 Sep 2026 10:13:40 -0700 Subject: [PATCH 39/41] origin is tainted on cross origin redirects --- src/network/CorsGate.zig | 21 +++++++++------------ src/network/HttpClient.zig | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig index f76185d47..b28b9970f 100644 --- a/src/network/CorsGate.zig +++ b/src/network/CorsGate.zig @@ -182,16 +182,17 @@ const Result = enum { allowed, pending }; pub fn check(self: *CorsGate, transfer: *Transfer) !Result { const req = &transfer.req; - if (req.origin) |origin| { - if (URL.isSameOrigin(req.url, origin)) { - log.debug(.cors, "same origin", .{ .url = req.url, .origin = origin }); - lp.metrics.cors_check.incr(.same_origin); - return .allowed; + if (!transfer._cors_origin_tainted) { + if (req.origin) |origin| { + if (URL.isSameOrigin(req.url, origin)) { + log.debug(.cors, "same origin", .{ .url = req.url, .origin = origin }); + lp.metrics.cors_check.incr(.same_origin); + return .allowed; + } } } - const origin = req.origin orelse "null"; - + const origin = transfer.effectiveOrigin(); transfer._cors_cross_origin = true; // https://fetch.spec.whatwg.org/#append-a-request-origin-header @@ -579,11 +580,7 @@ pub fn validateResponse(transfer: *Transfer) !void { } if (!is_wildcard_origin) { - const origin = req.origin orelse { - log.warn(.cors, "blocked", .{ .url = req.url, .reason = "opaque origin" }); - return error.CorsBlocked; - }; - + const origin = transfer.effectiveOrigin(); if (!std.mem.eql(u8, allow_origin, origin)) { log.warn(.cors, "blocked", .{ .url = req.url, diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index c2c1510eb..145aa75d9 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -2247,6 +2247,9 @@ pub const Transfer = struct { _retired: bool = false, _cors_cross_origin: bool = false, + // Set once a redirect target origin differs from origin of the URL + // that redirected to it. + _cors_origin_tainted: bool = false, pub const State = union(enum) { // Pre-commit. Only valid inside the request flow (Client.request @@ -2597,6 +2600,11 @@ pub const Transfer = struct { self.failAsync(err); } + pub fn effectiveOrigin(transfer: *const Transfer) []const u8 { + if (transfer._cors_origin_tainted) return "null"; + return transfer.req.origin orelse "null"; + } + pub fn getCookieString(self: *Transfer, arena: Allocator) !?[:0]const u8 { const req = &self.req; if (!req.credentialsAllowed()) return null; @@ -3291,6 +3299,14 @@ pub const Transfer = struct { } transfer.redirectTaint(url); + + if (transfer.req.request_mode == .cors and !transfer._cors_origin_tainted) { + const already_cross_origin = if (req.origin) |o| !URL.isSameOrigin(base, o) else true; + if (already_cross_origin and !URL.isSameOrigin(url, base)) { + transfer._cors_origin_tainted = true; + } + } + try transfer.updateURL(url); // 301, 302, 303 → change to GET, drop body. // 307, 308 → keep method and body. From 6bf77ad0f2b0cb7c0996befd06419d5c5d2ad843 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Thu, 3 Sep 2026 10:25:48 -0700 Subject: [PATCH 40/41] make test client buf larger to accomodate larger metrics --- src/server/Server.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/Server.zig b/src/server/Server.zig index ed1c57166..4aa575eb5 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -1969,7 +1969,7 @@ fn createTestClient() !TestClient { const TestClient = struct { socket: posix.socket_t, - buf: [8192]u8 = undefined, + buf: [8192 * 2]u8 = undefined, reader: WS.ReaderNoMask, fn deinit(self: *TestClient) void { From 620df371d35826794e910514d075886e21dde00f Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Fri, 4 Sep 2026 06:58:30 -0700 Subject: [PATCH 41/41] avoid total capacity reservation on opaque fetch --- src/browser/webapi/net/Fetch.zig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 9de5585da..2ca69c53e 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -150,6 +150,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { const self: *Fetch = @ptrCast(@alignCast(transfer.req.ctx)); + const is_opaque = self._no_cors and transfer.client.obey_cors and transfer._cors_cross_origin; if (self._signal) |signal| { if (signal._aborted) { @@ -158,8 +159,10 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { } const arena = self._response._arena; - if (transfer.getContentLength()) |cl| { - try self._buf.ensureTotalCapacityPrecise(arena.allocator(), cl); + if (!is_opaque) { + if (transfer.getContentLength()) |cl| { + try self._buf.ensureTotalCapacityPrecise(arena.allocator(), cl); + } } const res = self._response; @@ -179,7 +182,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { // no-cors mode: regardless of what the server returned, JS only ever sees // an opaque response — status 0, no headers, no body, url "". - if (self._no_cors and transfer.client.obey_cors and transfer._cors_cross_origin) { + if (is_opaque) { res._status = 0; res._status_text = ""; res._url = "";