From 212c806be57170f5ebfbac4414475264eacf424d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 17 Aug 2026 08:24:03 -0700 Subject: [PATCH] add basic CorsGate scaffold --- src/log.zig | 1 + src/network/CorsGate.zig | 120 +++++++++++++++++++++++++++++++++++++ src/network/HttpClient.zig | 41 +++++++++++-- 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/network/CorsGate.zig diff --git a/src/log.zig b/src/log.zig index 82ed15c6e..8afe305cc 100644 --- a/src/log.zig +++ b/src/log.zig @@ -42,6 +42,7 @@ pub const Scope = enum { telemetry, unknown_prop, websocket, + cors, }; pub const num_scopes = @typeInfo(Scope).@"enum".fields.len; diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig new file mode 100644 index 000000000..ea4753f31 --- /dev/null +++ b/src/network/CorsGate.zig @@ -0,0 +1,120 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const URL = @import("../browser/URL.zig"); +const ArenaPool = @import("../ArenaPool.zig"); + +const http = @import("http.zig"); +const Network = @import("Network.zig"); +const Transfer = @import("HttpClient.zig").Transfer; +const SingleFlight = @import("SingleFlight.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +const CorsGate = @This(); + +network: *Network, +single_flight: SingleFlight, + +pub fn deinit(self: *CorsGate) void { + self.single_flight.deinit(); +} + +pub fn remove(self: *CorsGate, transfer: *Transfer) void { + self.single_flight.remove(transfer); +} + +fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { + var queued = self.single_flight.take(key) orelse return; + defer queued.deinit(self.single_flight.allocator); + + for (queued.items) |transfer| { + transfer.unpark(); + + if (!allowed) { + log.warn(.http, "blocked by cors (preflight)", .{ .url = transfer.req.url }); + transfer.failAsync(error.CorsBlocked); + continue; + } + + transfer.client.resumeAfterCors(transfer) catch |e| { + transfer.abortPipelineError(e); + }; + } +} + +fn isSafelistedContentType(value: []const u8) bool { + const semi = std.mem.indexOfScalar(u8, value, ';') orelse value.len; + const mime = std.mem.trim(u8, value[0..semi], &std.ascii.whitespace); + return std.ascii.eqlIgnoreCase(mime, "application/x-www-form-urlencoded") or + std.ascii.eqlIgnoreCase(mime, "multipart/form-data") or + std.ascii.eqlIgnoreCase(mime, "text/plain"); +} + +fn isSafelistedHeader(name: []const u8, value: []const u8) bool { + if (std.ascii.eqlIgnoreCase(name, "accept") or + std.ascii.eqlIgnoreCase(name, "accept-language") or + std.ascii.eqlIgnoreCase(name, "content-language")) + { + return true; + } + if (std.ascii.eqlIgnoreCase(name, "content-type")) { + return isSafelistedContentType(value); + } + return false; +} + +fn requiresPreflight(transfer: *const Transfer) bool { + const req = &transfer.req; + + switch (req.method) { + .GET, .HEAD, .POST => {}, + else => return true, + } + + for (transfer.req_headers.items) |hdr| { + // Only authored headers can trigger a preflight + if (hdr.source != .author) continue; + if (!isSafelistedHeader(hdr.name, hdr.value)) return true; + } + + return false; +} + +const Result = enum { allowed, blocked, pending }; + +pub fn check(self: *CorsGate, transfer: *Transfer) !Result { + _ = self; + const req = &transfer.req; + + if (req.origin) |origin| { + if (URL.isSameOrigin(req.url, origin)) { + log.debug(.cors, "same origin", .{ .url = req.url, .origin = origin }); + return .allowed; + } + } + + log.debug(.cors, "cross origin", .{ .url = req.url, .origin = req.origin orelse "null" }); + transfer._cors_cross_origin = true; + + return .blocked; +} diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 936a6f4f1..f871fc703 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -35,6 +35,7 @@ const http = @import("http.zig"); const Network = @import("Network.zig"); const Cache = @import("cache/Cache.zig"); const RobotsGate = @import("RobotsGate.zig"); +const CorsGate = @import("CorsGate.zig"); const UrlBlocklist = @import("UrlBlocklist.zig"); pub const BlockPattern = UrlBlocklist.Pattern; @@ -187,6 +188,7 @@ obey_robots: bool, http_version: lp.Config.HttpVersion, robots: RobotsGate, +cors: CorsGate, url_blocklist: ?UrlBlocklist, pub fn init(self: *Client, app: *lp.App) !void { @@ -233,6 +235,10 @@ pub fn init(self: *Client, app: *lp.App) !void { .network = network, .single_flight = .init(allocator), }, + .cors = .{ + .network = network, + .single_flight = .init(allocator), + }, .url_blocklist = url_blocklist, .arena_pool = &app.arena_pool, }; @@ -264,6 +270,7 @@ pub fn deinit(self: *Client) void { self.clearUrlBlocklist(); self.robots.deinit(); + self.cors.deinit(); self.blocking_requests.deinit(self.allocator); self.transfers.deinit(self.allocator); self.cache.maintenance(lp.datetime.timestamp(.real)); @@ -461,6 +468,7 @@ pub fn abort(self: *Client) void { // - self.robots.pending : each robots fetch's shutdown_callback // drops its entry; parked waiters unlink in their own deinit. std.debug.assert(self.robots.single_flight.count() == 0); + std.debug.assert(self.cors.single_flight.count() == 0); } } @@ -962,6 +970,7 @@ const SubmitFrom = enum { start, // Transfer.submit — a brand new request. redirect, // Followed 3xx. Same as .start, but a distinct name (e.g. for CDP) after_intercept, // Released by CDP + after_cors, // cors allowed the request. throttle, // the robots gate allowed the request. network, // released by throttle }; @@ -1015,9 +1024,18 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { return transfer.failAsync(error.UrlBlocked); } if (try self.cacheLookup(transfer)) { - // response came from the cache, we're done return; } + if (!transfer.req.internal) { + switch (try self.cors.check(transfer)) { + .allowed => {}, + .blocked => return transfer.failAsync(error.CorsBlocked), + .pending => return, + } + } + continue :sw SubmitFrom.after_cors; + }, + .after_cors => { if (self.obey_robots and !transfer.req.internal) { switch (try self.robots.check(transfer)) { .allowed => { @@ -1059,6 +1077,12 @@ pub fn resumeAfterRobots(self: *Client, transfer: *Transfer) !void { return self.pipeline(transfer, .throttle); } +// CorsGate resumption after a preflight resolves as allowed. Re-enters +// right after the CORS step (not .after_intercept) +pub fn resumeAfterCors(self: *Client, transfer: *Transfer) !void { + return self.pipeline(transfer, .after_cors); +} + fn findHeader(headers: []const http.Header, name: []const u8) ?[]const u8 { for (headers) |hdr| { if (std.ascii.eqlIgnoreCase(hdr.name, name)) { @@ -2162,6 +2186,8 @@ pub const Transfer = struct { // everything and sits on client.graveyard _retired: bool = false, + _cors_cross_origin: bool = false, + pub const State = union(enum) { // Pre-commit. Only valid inside the request flow (Client.request // or a re-entry like continueTransfer / unpark) before any commit @@ -2214,6 +2240,9 @@ pub const Transfer = struct { // RobotsGate holds the transfer pending a robots.txt fetch. robots, + + // CorsGate holds the tranfer pending a CORS preflight. + cors, }; pub const HeaderResult = enum { @@ -2249,7 +2278,7 @@ pub const Transfer = struct { return; } switch (self.state.parked) { - .robots => {}, + .robots, .cors => {}, .intercept_request, .intercept_auth => { lp.assert(self.client.intercepted > 0, "Transfer.leaveIntercept", .{ .value = self.client.intercepted }); self.client.intercepted -= 1; @@ -2388,8 +2417,12 @@ pub const Transfer = struct { // And for the robots gate: RobotsGate.pending holds a raw *Transfer // while we're parked. - if (self.state == .parked and self.state.parked == .robots) { - self.client.robots.remove(self); + if (self.state == .parked) { + switch (self.state.parked) { + .cors => self.client.cors.remove(self), + .robots => self.client.robots.remove(self), + .intercept_auth, .intercept_request => {}, + } } // A pending revalidation entry owns cache resources (possibly an