diff --git a/src/Config.zig b/src/Config.zig index 262ab020e..c5d1fa360 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -244,6 +244,10 @@ 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 }, @@ -280,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 }, @@ -555,6 +560,13 @@ pub fn httpVersion(self: *const Config) HttpVersion { }; } +pub fn experimentalFeatures(self: *const Config) ExperimentalFeatures { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.experimental_features, + else => unreachable, + }; +} + pub fn watchdogMs(self: *const Config) ?u32 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| { 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/browser/Frame.zig b/src/browser/Frame.zig index dff8f8bd5..8939cee6c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -857,8 +857,10 @@ 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, + .request_mode = .navigate, + .credentials_mode = .include, .header_callback = frameHeaderDoneCallback, .data_callback = frameDataCallback, .done_callback = frameDoneCallback, @@ -2382,6 +2384,9 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co const transfer = http_client.newRequest(.{ .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/ScriptManager.zig b/src/browser/ScriptManager.zig index bb0d9db08..10410d967 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 = .include }, + .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,11 +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, @@ -342,10 +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); { @@ -385,11 +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, diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 1f6a8ce97..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, @@ -249,6 +255,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 +447,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 941daae25..b66febc2a 100644 --- a/src/browser/frame/resource_load.zig +++ b/src/browser/frame/resource_load.zig @@ -79,6 +79,9 @@ pub fn image(frame: *Frame, img: *Element.Html.Image, src: []const u8) !void { .ctx = load, .url = resolved, .method = .GET, + .origin = frame.origin, + .request_mode = .no_cors, + .credentials_mode = .include, .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 84b5b9039..b7f3b9cbb 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -105,6 +105,9 @@ 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, + .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 7b6751250..a1c5cd22f 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -106,6 +106,9 @@ 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, + .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 75c2d0e62..8bcf7f25b 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -430,6 +430,9 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! .url = resolved_url, .method = .GET, .resource_type = .worker, + .origin = self.origin, + .request_mode = .no_cors, + .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 93534a2d1..8a7424a8a 100644 --- a/src/browser/webapi/net/EventSource.zig +++ b/src/browser/webapi/net/EventSource.zig @@ -172,13 +172,14 @@ 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, .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..2ca69c53e 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; @@ -56,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(); @@ -81,6 +88,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) { @@ -93,11 +101,18 @@ 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, + .include => .include, }, + .request_mode = switch (request._mode) { + .cors => .cors, + .@"no-cors" => .no_cors, + .@"same-origin" => .same_origin, + .navigate => @panic("fetch can't be navigate mode"), + }, + .origin = exec.origin(), .redirect = switch (request._redirect) { .follow => .follow, .manual => .manual, @@ -135,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) { @@ -143,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; @@ -162,6 +180,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 (is_opaque) { + 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)) { @@ -210,6 +239,10 @@ fn httpDataCallback(transfer: *Transfer, data: []const u8) !void { } } + if (self._no_cors and transfer.client.obey_cors and transfer._cors_cross_origin) { + 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..88d520a32 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, @@ -60,6 +61,7 @@ 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, 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/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index de328b8ea..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,9 @@ 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(), .resource_type = .xhr, .timeout_ms = self._timeout, .header_callback = httpHeaderDoneCallback, @@ -337,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/help.zon b/src/help.zon index d3aec0b9a..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. 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..b28b9970f --- /dev/null +++ b/src/network/CorsGate.zig @@ -0,0 +1,608 @@ +// 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 HttpClient = @import("HttpClient.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const CorsGate = @This(); + +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_ALLOW_CREDENTIALS = "access-control-allow-credentials"; + +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) { + 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); + }; + } +} + +fn isSafelistedMethod(value: http.Method) bool { + return switch (value) { + .GET, .HEAD, .POST => true, + else => false, + }; +} + +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); + 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 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 (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 isSafelistedLanguageValue(value); + } + + if (std.ascii.eqlIgnoreCase(name, "content-type")) { + return isSafelistedContentType(value) and + hasNoCorsUnsafeBytes(value); + } + + return false; +} + +fn requiresPreflight(transfer: *const Transfer) bool { + const req = &transfer.req; + + if (!isSafelistedMethod(req.method)) { + 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, pending }; + +pub fn check(self: *CorsGate, transfer: *Transfer) !Result { + const req = &transfer.req; + + 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 = transfer.effectiveOrigin(); + 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, + .origin = origin, + .mode = "no-cors", + }); + lp.metrics.cors_check.incr(.no_cors); + return .allowed; + } + + if (!requiresPreflight(transfer)) { + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = origin, + .preflight = false, + }); + lp.metrics.cors_check.incr(.simple); + return .allowed; + } + + log.debug(.cors, "cross origin", .{ + .url = req.url, + .origin = origin, + .preflight = true, + }); + lp.metrics.cors_check.incr(.preflight); + + try self.fetchThenResume(transfer); + return .pending; +} + +const CorsKey = struct { + url: []const u8, + origin: []const u8, + method: http.Method, + wants_credentials: bool, + // 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); + 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); + 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, + request_headers: []const []const u8, + wants_credentials: bool, + + allowed: bool = false, + + fn validateHeaders( + self: *CorsPreflightContext, + acao: ?[]const u8, + acam: ?[]const u8, + acah: ?[]const u8, + acac: ?[]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; + }; + + 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", + .allow_origin = allow_origin, + .origin = self.origin, + }); + 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; + } + } + + 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; + } + } + + // 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; + }; + + const headers_wildcard = std.mem.eql(u8, allow_headers, "*") and !self.wants_credentials; + + 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; + } + } + } + + 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, "preflight blocked", .{ .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 acac: ?[]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; + } else if (std.ascii.eqlIgnoreCase(ACCESS_CONTROL_ALLOW_CREDENTIALS, hdr.name)) { + acac = hdr.value; + } + } + + self.allowed = self.validateHeaders(acao, acam, acah, acac); + 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.key); + arena.release(); + } + + fn resolve(self: *CorsPreflightContext, allowed: bool) void { + const gate = self.gate; + const arena = self.arena; + 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"; + + var header_names: std.ArrayList([]const u8) = .empty; + for (transfer.req_headers.items) |hdr| { + if (hdr.source != .author) continue; + if (isSafelistedHeader(hdr.name, hdr.value)) continue; + try header_names.append( + transfer.arena.allocator(), + try std.ascii.allocLowerString(transfer.arena.allocator(), hdr.name), + ); + } + std.mem.sort([]const u8, header_names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + const cors_key = CorsKey{ + .url = url, + .origin = origin, + .method = transfer.req.method, + .wants_credentials = transfer.req.credentials_mode == .include, + .authored_headers = header_names.items, + }; + 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(key); + transfer.unpark(); + } + + 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); + const owned_key = try arena.dupe(u8, key); + const owned_origin = try arena.dupe(u8, origin); + + 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, + + .key = owned_key, + .url = owned_url, + .origin = owned_origin, + .method = transfer.req.method, + .request_headers = owned_header_names, + .wants_credentials = transfer.req.credentials_mode == .include, + }; + + 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, + .origin = transfer.req.origin, + .credentials_mode = .omit, + .request_mode = .no_cors, + .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; + 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; + }; + + 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 = transfer.effectiveOrigin(); + 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; + } + } + + 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; + } + } + + lp.metrics.cors_response.incr(.allowed); +} diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 902994691..145aa75d9 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; @@ -181,12 +182,14 @@ 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. http_version: lp.Config.HttpVersion, robots: RobotsGate, +cors: CorsGate, url_blocklist: ?UrlBlocklist, pub fn init(self: *Client, app: *lp.App) !void { @@ -229,10 +232,12 @@ 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.experimentalFeatures().cors, .robots = .{ .network = network, .single_flight = .init(allocator), }, + .cors = .{ .single_flight = .init(allocator) }, .url_blocklist = url_blocklist, .arena_pool = &app.arena_pool, }; @@ -264,6 +269,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)); @@ -411,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; } @@ -461,6 +476,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); } } @@ -617,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. @@ -626,10 +642,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); + + 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; + 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) @@ -958,6 +978,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 }; @@ -1010,10 +1031,28 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { log.info(.http, "blocked url", .{ .url = transfer.req.url }); return transfer.failAsync(error.UrlBlocked); } + + 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, + } + } + continue :sw SubmitFrom.after_cors; + }, + .after_cors => { if (try self.cacheLookup(transfer)) { - // response came from the cache, we're done return; } + if (self.obey_robots and !transfer.req.internal) { switch (try self.robots.check(transfer)) { .allowed => { @@ -1055,7 +1094,13 @@ pub fn resumeAfterRobots(self: *Client, transfer: *Transfer) !void { return self.pipeline(transfer, .throttle); } -fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 { +// 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); +} + +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; @@ -1520,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. // @@ -1615,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) { @@ -1659,6 +1719,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T } try transfer.materializeResponse(msg.conn, .{}); + if (self.enforceCorsResponse(msg, transfer)) return true; // Latency is only meaningful for responses that hit the network (cache // and synthetic responses never reach processOneMessage). @@ -1772,13 +1833,31 @@ 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, + }; + + pub const RequestMode = enum { + cors, + no_cors, + same_origin, + navigate, + }; + 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, + request_mode: RequestMode, timeout_ms: u32 = 0, skip_cache: bool = false, @@ -1799,15 +1878,14 @@ 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. 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, @@ -1843,6 +1921,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 { @@ -1936,10 +2025,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); + } } } } @@ -2155,6 +2246,11 @@ pub const Transfer = struct { // everything and sits on client.graveyard _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 // or a re-entry like continueTransfer / unpark) before any commit @@ -2207,6 +2303,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 { @@ -2242,7 +2341,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; @@ -2381,8 +2480,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 @@ -2497,9 +2600,16 @@ 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 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, @@ -2970,13 +3080,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; + }; + } } } } @@ -3049,7 +3161,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 { @@ -3187,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. @@ -3234,7 +3354,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 { @@ -4143,6 +4263,9 @@ 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, .shutdown_callback = noopShutdown, }, @@ -4371,6 +4494,9 @@ 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, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4451,6 +4577,9 @@ 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, .shutdown_callback = Ctx.shutdownCallback, .ctx = &ctx, @@ -4532,6 +4661,9 @@ 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, .shutdown_callback = Ctx.shutdownCallback, .ctx = &ctx, @@ -4588,6 +4720,9 @@ 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, .shutdown_callback = noopShutdown, }, @@ -4650,7 +4785,10 @@ 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, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, @@ -4689,7 +4827,10 @@ 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, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = undefined, @@ -4753,6 +4894,9 @@ 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, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4816,6 +4960,9 @@ test "HttpClient: abortParked survives an error_callback that tears down the own .req = .{ .method = .GET, .url = "http://example.com/", + .origin = null, + .credentials_mode = .omit, + .request_mode = .no_cors, .resource_type = .document, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4889,6 +5036,9 @@ 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, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4921,6 +5071,9 @@ 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, .shutdown_callback = noopShutdown, .ctx = &ctx, @@ -4980,6 +5133,9 @@ 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, .shutdown_callback = noopShutdown, .ctx = undefined, diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index 19a737ad9..49ba8113c 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -116,6 +116,9 @@ 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, + .credentials_mode = .omit, + .request_mode = .no_cors, .ctx = robots_ctx, .header_callback = RobotsContext.headerCallback, .data_callback = RobotsContext.dataCallback, diff --git a/src/network/SingleFlight.zig b/src/network/SingleFlight.zig index 76deabb30..6952c040d 100644 --- a/src/network/SingleFlight.zig +++ b/src/network/SingleFlight.zig @@ -106,6 +106,9 @@ 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, .shutdown_callback = HttpClient.noopShutdown, }, diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index 9fc80cc89..79e2245a0 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -244,6 +244,9 @@ 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, .shutdown_callback = @import("HttpClient.zig").noopShutdown, }, 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 { diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index e9c431b65..6960946b6 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -1516,6 +1516,9 @@ 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, .shutdown_callback = HttpClient.noopShutdown, }, null); diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 12a43bc3d..beee2fc9c 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -1177,6 +1177,9 @@ 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, .notification = bc.session.notification, .ctx = &error_context, @@ -1201,6 +1204,9 @@ 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, .notification = bc.session.notification, .ctx = &error_context, @@ -1239,7 +1245,10 @@ 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, .resource_type = .fetch, .notification = bc.session.notification, .shutdown_callback = HttpClient.noopShutdown, @@ -1300,6 +1309,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, @@ -1501,6 +1513,9 @@ 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, .notification = bc.session.notification, .ctx = &callback_context,