mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 01:36:15 -04:00
add CorsGate
This commit is contained in:
277
src/network/CorsGate.zig
Normal file
277
src/network/CorsGate.zig
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user