mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-17 08:27:11 -04:00
Merge pull request #3002 from lightpanda-io/cors-impl
CORS Implementation
This commit is contained in:
23 files changed
+993
-42
No files matched your search
@@ -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| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 <link>.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,14 @@
|
||||
\\ --cookie-jar <PATH>
|
||||
\\ Path to a JSON file to save cookies to on exit (write-only).
|
||||
\\ Defaults to no cookie saving.
|
||||
\\ --experimental-features <FEATURE>
|
||||
\\ 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 <RESOURCE>
|
||||
\\ Sub-resource to actually request. Can be passed multiple times.
|
||||
\\ Defaults to requesting none of them.
|
||||
|
||||
@@ -42,6 +42,7 @@ pub const Scope = enum {
|
||||
telemetry,
|
||||
unknown_prop,
|
||||
websocket,
|
||||
cors,
|
||||
};
|
||||
|
||||
pub const num_scopes = @typeInfo(Scope).@"enum".fields.len;
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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);
|
||||
}
|
||||
+183
-27
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user