diff --git a/src/network/CorsGate.zig b/src/network/CorsGate.zig new file mode 100644 index 000000000..6ba4ceb66 --- /dev/null +++ b/src/network/CorsGate.zig @@ -0,0 +1,277 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const http = @import("http.zig"); +const Request = @import("../browser/webapi/net/Request.zig"); +const Network = @import("Network.zig"); +const Robots = @import("Robots.zig"); +const SingleFlight = @import("SingleFlight.zig"); +const Transfer = @import("HttpClient.zig").Transfer; +const ArenaPool = @import("../ArenaPool.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +pub const CorsGate = @This(); + +network: *Network, +allocator: Allocator, +single_flight: SingleFlight, + +pub const Result = enum { allowed, blocked, pending }; + +pub fn deinit(self: *CorsGate) void { + self.single_flight.deinit(); +} + +fn isSafelistedHeader(name: []const u8) bool { + const safelisted = [_][]const u8{ + "accept", + "accept-language", + "content-language", + "content-type", + }; + + for (safelisted) |s| if (std.ascii.eqlIgnoreCase(name, s)) return true; + return false; +} + +fn isSafelistedContentType(ct: []const u8) bool { + const safelisted = [_][]const u8{ + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", + }; + + const mime = blk: { + const semi = std.mem.indexOfScalar(u8, ct, ';') orelse ct.len; + break :blk std.mem.trim(u8, ct[0..semi], &std.ascii.whitespace); + }; + + for (safelisted) |s| if (std.ascii.eqlIgnoreCase(mime, s)) return true; + return false; +} + +pub fn needsPreflight(transfer: *Transfer) bool { + const req = &transfer.req; + + var content_type: ?[]const u8 = null; + var it = req.headers.iterator(); + while (it.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "content-type")) { + content_type = hdr.value; + continue; + } + + if (!isSafelistedHeader(hdr.name)) return true; + } + + switch (req.method) { + .GET, .HEAD => {}, + .POST => if (content_type) |ct| if (isSafelistedContentType(ct)) return true, + else => return true, + } + + return false; +} + +fn keyFor(arena: Allocator, transfer: *Transfer) ![]const u8 { + const req = transfer.req; + + return try std.fmt.allocPrint( + arena, + "{s}-{s}-{s}", + .{ req.cookie_origin, @tagName(req.method), req.url }, + ); +} + +pub fn check(self: *CorsGate, transfer: *Transfer) !Result { + if (!needsPreflight(transfer)) return .allowed; + + const client = transfer.client; + const arena = try client.arena_pool.acquire(.small, "CorsGate.CorsContext"); + errdefer client.arena_pool.release(arena); + + const key = try keyFor(arena, transfer); + + try self.fetchThenResume(arena, key, transfer); + return .pending; +} + +fn fetchThenResume(self: *CorsGate, arena: Allocator, key: []const u8, transfer: *Transfer) !void { + const client = transfer.client; + + const res = try self.single_flight.enter(key, transfer, .cors); + switch (res) { + .queued => { + // Joined an in-flight preflight — this context/arena was + // never registered or handed to a fetch, release it now. + client.arena_pool.release(arena); + return; + }, + .initial => { + errdefer self.single_flight.abort(key); + + const cors_ctx = try arena.create(CorsContext); + cors_ctx.* = .{ + .gate = self, + .arena = arena, + .arena_pool = client.arena_pool, + .key = key, + }; + + log.debug(.browser, "sending cors preflight", .{ .url = transfer.req.url }); + + var headers = try client.newHeaders(); + errdefer headers.deinit(); + + try preflightHeaders(arena, &headers, transfer); + + const fetch_transfer = try client.newRequest(.{ + .url = transfer.req.url, + .method = .OPTIONS, + .internal = true, + .resource_type = .fetch, + .frame_id = transfer.req.frame_id, + .loader_id = transfer.req.loader_id, + .notification = transfer.req.notification, + .cookie_jar = null, + .cookie_origin = transfer.req.cookie_origin, + .ctx = cors_ctx, + .headers = headers, + .header_callback = CorsContext.headerCallback, + .done_callback = CorsContext.doneCallback, + .error_callback = CorsContext.errorCallback, + .shutdown_callback = CorsContext.shutdownCallback, + }, null); + + fetch_transfer.submit() catch {}; + }, + } +} + +fn preflightHeaders(arena: Allocator, headers: *http.Headers, transfer: *Transfer) !void { + try headers.add(try std.fmt.allocPrintSentinel( + arena, + "Access-Control-Request-Method: {s}", + .{@tagName(transfer.req.method)}, + 0, + )); + + // Non-safelisted request headers must be listed too, comma-separated, + // per the Fetch spec's preflight algorithm. + var names: std.ArrayList(u8) = .empty; + var it = transfer.req.headers.iterator(); + var first = true; + while (it.next()) |hdr| { + if (isSafelistedHeader(hdr.name)) continue; + if (!first) try names.appendSlice(arena, ", "); + try names.appendSlice(arena, hdr.name); + first = false; + } + if (names.items.len > 0) { + try headers.add(try std.fmt.allocPrintSentinel( + arena, + "Access-Control-Request-Headers: {s}", + .{names.items}, + 0, + )); + } + + try headers.add(try std.fmt.allocPrintSentinel( + arena, + "Origin: {s}", + .{transfer.req.cookie_origin}, + 0, + )); +} + +const CorsContext = struct { + gate: *CorsGate, + arena: Allocator, + arena_pool: *ArenaPool, + key: []const u8, + status: u16 = 0, + + fn headerCallback(transfer: *Transfer) anyerror!Transfer.HeaderResult { + const self: *CorsContext = @ptrCast(@alignCast(transfer.req.ctx)); + if (transfer.res.header) |hdr| { + log.debug(.browser, "cors preflight status", .{ .status = hdr.status, .key = self.key }); + self.status = hdr.status; + } + // No body needed — everything relevant is in the response headers. + return .proceed; + } + + fn doneCallback(ctx_ptr: *anyopaque) anyerror!void { + const self: *CorsContext = @ptrCast(@alignCast(ctx_ptr)); + // TODO: validate Access-Control-Allow-Origin / -Methods / -Headers + // against the original request rather than just checking status. + const allowed = self.status >= 200 and self.status < 300; + self.resolve(allowed); + } + + fn errorCallback(ctx_ptr: *anyopaque, err: anyerror) void { + const self: *CorsContext = @ptrCast(@alignCast(ctx_ptr)); + log.warn(.http, "cors preflight failed", .{ .err = err, .key = self.key }); + self.resolve(false); + } + + fn shutdownCallback(ctx_ptr: *anyopaque) void { + const self: *CorsContext = @ptrCast(@alignCast(ctx_ptr)); + log.debug(.http, "cors preflight shutdown", .{}); + const gate = self.gate; + const pool = self.arena_pool; + const arena = self.arena; + gate.single_flight.discard(self.key); + pool.release(arena); + } + + fn resolve(self: *CorsContext, allowed: bool) void { + const gate = self.gate; + const pool = self.arena_pool; + const arena = self.arena; + gate.flushPending(self.key, allowed); + pool.release(arena); + } +}; + +// The preflight resolved: hand every waiter back to the pipeline. Unlike +// robots, a failed/errored preflight fails CLOSED — CORS is a security +// boundary, not a courtesy. +fn flushPending(self: *CorsGate, key: []const u8, allowed: bool) void { + var queued = self.single_flight.take(key) orelse return; + defer queued.deinit(self.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); + }; + } +} diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 501979085..996f03cba 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -37,6 +37,7 @@ const Cache = @import("cache/Cache.zig"); const RobotsGate = @import("RobotsGate.zig"); const UrlBlocklist = @import("UrlBlocklist.zig"); pub const BlockPattern = UrlBlocklist.Pattern; +const CorsGate = @import("CorsGate.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -189,6 +190,7 @@ obey_robots: bool, robots: RobotsGate, url_blocklist: ?UrlBlocklist, +cors: CorsGate, pub fn init(self: *Client, allocator: Allocator, network: *Network, cdp: ?*CDP) !void { var handles = try http.Handles.init(network.config); @@ -227,6 +229,7 @@ pub fn init(self: *Client, allocator: Allocator, network: *Network, cdp: ?*CDP) .serve_mode = network.config.mode == .serve, .obey_robots = network.config.obeyRobots(), .robots = .{ .allocator = allocator, .network = network, .single_flight = .{ .allocator = allocator } }, + .cors = .{ .allocator = allocator, .network = network, .single_flight = .{ .allocator = allocator } }, .url_blocklist = url_blocklist, .arena_pool = &network.app.arena_pool, }; @@ -258,6 +261,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.inbox.deinit(self.arena_pool); @@ -432,6 +436,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); } } @@ -840,6 +845,8 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { try wba.signRequest(transfer.arena, &transfer.req.headers, authority); } + try addOriginHeader(transfer); + if (self.serve_mode) { transfer._notify_cdp = true; transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer }); @@ -872,15 +879,23 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { // response came from the cache, we're done return; } - if (self.obey_robots and !transfer.req.internal) { - switch (try self.robots.check(transfer)) { - .allowed => { - lp.metrics.robots_access.incr(.allow); - }, - .blocked => { - lp.metrics.robots_access.incr(.deny); - return transfer.failAsync(error.RobotsBlocked); - }, + if (!transfer.req.internal) { + if (self.obey_robots) { + switch (try self.robots.check(transfer)) { + .allowed => { + lp.metrics.robots_access.incr(.allow); + }, + .blocked => { + lp.metrics.robots_access.incr(.deny); + return transfer.failAsync(error.RobotsBlocked); + }, + .pending => return, + } + } + + switch (try self.cors.check(transfer)) { + .allowed => {}, + .blocked => return transfer.failAsync(error.CorsBlocked), .pending => return, } } @@ -890,6 +905,26 @@ fn pipeline(self: *Client, transfer: *Transfer, from: SubmitFrom) !void { } } +fn addOriginHeader(transfer: *Transfer) !void { + const req = &transfer.req; + const is_cross_origin = !URL.isSameOrigin(req.cookie_origin, req.url); + const is_unsafe_method = switch (req.method) { + .GET, .HEAD => false, + else => true, + }; + if (!is_cross_origin and !is_unsafe_method) { + return; + } + const origin = try std.fmt.allocPrintSentinel(transfer.arena, "Origin: {s}", .{req.cookie_origin}, 0); + try req.headers.add(origin); +} + +// CorsGate resumption. The robots gate is the last step before the +// network, so an allowed transfer goes straight there. +pub fn resumeAfterCors(self: *Client, transfer: *Transfer) !void { + return self.pipeline(transfer, .network); +} + // RobotsGate resumption. The robots gate is the last step before the // network, so an allowed transfer goes straight there. pub fn resumeAfterRobots(self: *Client, transfer: *Transfer) !void { @@ -1996,6 +2031,9 @@ pub const Transfer = struct { // RobotsGate holds the transfer pending a robots.txt fetch. robots, + + // CorsGate holds the transfer pending a CORS Preflight. + cors, }; pub const HeaderResult = enum { @@ -2031,7 +2069,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;