mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 17:25:58 -04:00
419 lines
14 KiB
Zig
419 lines
14 KiB
Zig
// 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 URL = @import("../browser/URL.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;
|
|
if (req.internal) return false;
|
|
if (req.resource_type == .document) return false;
|
|
if (URL.isSameOrigin(req.url, req.cookie_origin)) return false;
|
|
|
|
for (req.authored_headers) |name| {
|
|
if (!isSafelistedHeader(name)) return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 remove(self: *CorsGate, transfer: *Transfer) void {
|
|
self.single_flight.remove(transfer);
|
|
}
|
|
|
|
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 req_headers = try requestedHeaderNames(arena, transfer);
|
|
const cors_ctx = try arena.create(CorsContext);
|
|
cors_ctx.* = .{
|
|
.gate = self,
|
|
.arena = arena,
|
|
.arena_pool = client.arena_pool,
|
|
.key = key,
|
|
|
|
.req_origin = try arena.dupe(u8, transfer.req.cookie_origin),
|
|
.req_method = transfer.req.method,
|
|
.req_headers = req_headers,
|
|
// TODO: add credentials mode to request.
|
|
.req_credentials = .omit,
|
|
};
|
|
|
|
log.debug(.cors, "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 requestedHeaderNames(arena: Allocator, transfer: *Transfer) ![]const []const u8 {
|
|
var names: std.ArrayList([]const u8) = .empty;
|
|
for (transfer.req.authored_headers) |name| {
|
|
if (isSafelistedHeader(name)) continue;
|
|
try names.append(arena, try arena.dupe(u8, name));
|
|
}
|
|
return names.items;
|
|
}
|
|
|
|
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 first = true;
|
|
for (transfer.req.authored_headers) |name| {
|
|
if (isSafelistedHeader(name)) continue;
|
|
if (!first) try names.appendSlice(arena, ", ");
|
|
try names.appendSlice(arena, 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,
|
|
));
|
|
}
|
|
|
|
pub const CorsEntry = struct {
|
|
const Origin = union(enum) { wildcard, value: []const u8 };
|
|
const Credentials = enum { omit, include, @"same-origin" };
|
|
|
|
origin: ?Origin = null,
|
|
credentials: Credentials = .omit,
|
|
methods: []http.Method = &.{},
|
|
headers: [][]const u8 = &.{},
|
|
expose_headers: [][]const u8 = &.{},
|
|
|
|
pub fn parse(arena: Allocator, transfer: *Transfer) !CorsEntry {
|
|
var entry: CorsEntry = .{};
|
|
|
|
var methods: std.ArrayList(http.Method) = .empty;
|
|
var headers: std.ArrayList([]const u8) = .empty;
|
|
var expose_headers: std.ArrayList([]const u8) = .empty;
|
|
|
|
var iter = transfer.responseHeaderIterator();
|
|
while (iter.next()) |hdr| {
|
|
if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) {
|
|
entry.origin = if (std.mem.eql(u8, hdr.value, "*"))
|
|
.wildcard
|
|
else
|
|
.{ .value = try arena.dupe(u8, hdr.value) };
|
|
} else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials")) {
|
|
if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, hdr.value, &std.ascii.whitespace), "true")) {
|
|
entry.credentials = .include;
|
|
}
|
|
} else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-methods")) {
|
|
var it = std.mem.splitScalar(u8, hdr.value, ',');
|
|
while (it.next()) |raw| {
|
|
const tok = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
|
if (tok.len == 0) continue;
|
|
if (std.mem.eql(u8, tok, "*")) continue; // handled separately as wildcard
|
|
if (std.meta.stringToEnum(http.Method, tok)) |m| {
|
|
try methods.append(arena, m);
|
|
}
|
|
}
|
|
} else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-headers")) {
|
|
var it = std.mem.splitScalar(u8, hdr.value, ',');
|
|
while (it.next()) |raw| {
|
|
const tok = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
|
if (tok.len == 0) continue;
|
|
try headers.append(arena, try arena.dupe(u8, tok));
|
|
}
|
|
} else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-expose-headers")) {
|
|
var it = std.mem.splitScalar(u8, hdr.value, ',');
|
|
while (it.next()) |raw| {
|
|
const tok = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
|
if (tok.len == 0) continue;
|
|
try expose_headers.append(arena, try arena.dupe(u8, tok));
|
|
}
|
|
}
|
|
}
|
|
|
|
entry.methods = methods.items;
|
|
entry.headers = headers.items;
|
|
entry.expose_headers = expose_headers.items;
|
|
return entry;
|
|
}
|
|
|
|
fn hasMethod(self: CorsEntry, method: http.Method) bool {
|
|
for (self.methods) |m| if (m == method) return true;
|
|
return false;
|
|
}
|
|
|
|
fn allowsHeader(self: CorsEntry, name: []const u8) bool {
|
|
for (self.headers) |h| {
|
|
if (std.mem.eql(u8, h, "*") or
|
|
std.ascii.eqlIgnoreCase(h, name)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
pub fn satisfies(
|
|
self: CorsEntry,
|
|
req_origin: []const u8,
|
|
req_method: http.Method,
|
|
req_headers: []const []const u8,
|
|
req_credentials: Credentials,
|
|
) bool {
|
|
const origin = self.origin orelse return false;
|
|
switch (origin) {
|
|
.wildcard => if (req_credentials == .include) return false,
|
|
.value => |v| if (!std.ascii.eqlIgnoreCase(v, req_origin)) return false,
|
|
}
|
|
|
|
if (req_credentials == .include and self.credentials != .include) return false;
|
|
|
|
const is_simple_method = req_method == .GET or req_method == .HEAD or req_method == .POST;
|
|
if (!is_simple_method and !self.hasMethod(req_method)) return false;
|
|
|
|
for (req_headers) |h| {
|
|
if (!self.allowsHeader(h)) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const CorsContext = struct {
|
|
gate: *CorsGate,
|
|
arena: Allocator,
|
|
arena_pool: *ArenaPool,
|
|
key: []const u8,
|
|
status: u16 = 0,
|
|
|
|
req_origin: []const u8,
|
|
req_method: http.Method,
|
|
req_headers: []const []const u8,
|
|
req_credentials: CorsEntry.Credentials,
|
|
|
|
entry: ?CorsEntry = null,
|
|
|
|
fn headerCallback(transfer: *Transfer) anyerror!Transfer.HeaderResult {
|
|
const self: *CorsContext = @ptrCast(@alignCast(transfer.req.ctx));
|
|
if (transfer.res.header) |hdr| {
|
|
log.debug(.cors, "cors preflight status", .{ .status = hdr.status, .key = self.key });
|
|
self.status = hdr.status;
|
|
}
|
|
|
|
self.entry = try CorsEntry.parse(self.arena, transfer);
|
|
return .proceed;
|
|
}
|
|
|
|
fn doneCallback(ctx_ptr: *anyopaque) anyerror!void {
|
|
const self: *CorsContext = @ptrCast(@alignCast(ctx_ptr));
|
|
|
|
const entry = self.entry orelse {
|
|
log.warn(.cors, "cors preflight rejected", .{ .key = self.key, .status = self.status });
|
|
self.resolve(false);
|
|
return;
|
|
};
|
|
|
|
const allowed = self.status >= 200 and self.status < 300 and
|
|
entry.satisfies(self.req_origin, self.req_method, self.req_headers, self.req_credentials);
|
|
|
|
if (!allowed) {
|
|
log.warn(.cors, "cors preflight rejected", .{ .key = self.key, .status = self.status });
|
|
}
|
|
self.resolve(allowed);
|
|
}
|
|
|
|
fn errorCallback(ctx_ptr: *anyopaque, err: anyerror) void {
|
|
const self: *CorsContext = @ptrCast(@alignCast(ctx_ptr));
|
|
log.warn(.cors, "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(.cors, "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);
|
|
}
|
|
};
|
|
|
|
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(.cors, "blocked by cors preflight", .{ .url = transfer.req.url });
|
|
transfer.failAsync(error.CorsBlocked);
|
|
continue;
|
|
}
|
|
|
|
transfer.client.resumeAfterCors(transfer) catch |e| {
|
|
transfer.abortPipelineError(e);
|
|
};
|
|
}
|
|
}
|