add CorsGate

This commit is contained in:
Muki Kiboigo
2026-07-18 12:42:56 -07:00
parent d4d8381ecb
commit 52033cffed
2 changed files with 325 additions and 10 deletions

277
src/network/CorsGate.zig Normal file
View File

@@ -0,0 +1,277 @@
// 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 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);
};
}
}

View File

@@ -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;