From 2577d240cf694636648065da334e9586c1763d4c Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Mon, 11 May 2026 21:08:24 +0600 Subject: [PATCH 01/18] feat: implement cors #2015 --- src/network/Cors.zig | 518 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 src/network/Cors.zig diff --git a/src/network/Cors.zig b/src/network/Cors.zig new file mode 100644 index 0000000000..281a297c8c --- /dev/null +++ b/src/network/Cors.zig @@ -0,0 +1,518 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Maifee Ul Asad +// +// 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 . + +//! Cross-Origin Resource Sharing (CORS) implementation + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Result of CORS processing for a request +pub const CorsResult = struct { + /// CORS headers to add to the response + headers: Headers, + /// Headers to add to Vary response header for caching + vary: [][]u8, + /// Optional response status code (e.g., 204 for preflight termination) + status: ?u16 = null, + + pub fn deinit(self: *CorsResult, allocator: Allocator) void { + for (0..self.headers.keys.len) |i| { + allocator.free(self.headers.keys[i]); + allocator.free(self.headers.values[i]); + } + allocator.free(self.headers.keys); + allocator.free(self.headers.values); + for (self.vary) |v| { + allocator.free(v); + } + allocator.free(self.vary); + } +}; + +/// A growable map of headers +pub const Headers = struct { + keys: [][]u8, + values: [][]u8, + + pub fn init(_: Allocator) Headers { + return .{ .keys = &.{}, .values = &.{} }; + } + + pub fn put(self: *Headers, allocator: Allocator, key: []const u8, value: []const u8) !void { + const key_copy = try allocator.dupe(u8, key); + errdefer allocator.free(key_copy); + + const value_copy = try allocator.dupe(u8, value); + errdefer allocator.free(value_copy); + + self.keys = try allocator.realloc(self.keys, self.keys.len + 1); + self.values = try allocator.realloc(self.values, self.values.len + 1); + self.keys[self.keys.len - 1] = key_copy; + self.values[self.values.len - 1] = value_copy; + } + + pub fn contains(self: *const Headers, key: []const u8) bool { + for (self.keys) |k| { + if (std.ascii.eqlIgnoreCase(k, key)) return true; + } + return false; + } + + pub fn get(self: *const Headers, key: []const u8) ?[]const u8 { + for (self.keys, 0..) |k, i| { + if (std.ascii.eqlIgnoreCase(k, key)) return self.values[i]; + } + return null; + } +}; + +/// Origin matching result +const OriginMatch = enum { yes, no, any }; + +/// Callback function type for dynamic origin validation +pub const OriginsFunction = *const fn (origin: []const u8) bool; + +/// Allowed origins: either a list of specific origins or a validation function +pub const Origins = union(enum) { + list: []const []const u8, + function: OriginsFunction, + + pub fn deinit(self: *Origins, allocator: Allocator) void { + _ = self; + _ = allocator; + // Origins are treated as borrowed slices by default. + } +}; + +/// CORS configuration options +pub const CorsOptions = struct { + /// Allowed origins for cross-origin requests + origins: Origins, + /// Allowed HTTP methods for cross-origin requests + methods: []const []const u8, + /// Allowed request headers for cross-origin requests + request_headers: []const []const u8, + /// Response headers to expose to JavaScript + response_headers: []const []const u8, + /// Whether to allow credentials (cookies, auth headers) + supports_credentials: bool, + /// Maximum age (in seconds) for preflight caching + max_age: ?u32, + /// Whether to end preflight requests with 204 No Content + end_preflight_requests: bool, + + pub fn defaultOptions() CorsOptions { + return .{ + .origins = .{ .list = &.{} }, + .methods = &.{ "GET", "HEAD", "POST" }, + .request_headers = &.{ "Accept", "Accept-Language", "Content-Language", "Content-Type", "Range" }, + .response_headers = &.{ "Cache-Control", "Content-Language", "Content-Type", "Expires", "Last-Modified", "Pragma" }, + .supports_credentials = false, + .max_age = null, + .end_preflight_requests = true, + }; + } + + pub fn deinit(self: *CorsOptions, allocator: Allocator) void { + self.origins.deinit(allocator); + } +}; + +/// Request headers as a simple map +pub const RequestHeaders = struct { + /// Header values (lowercase keys) + values: std.StringHashMap([]const u8), + allocator: Allocator, + + pub fn init(allocator: Allocator) RequestHeaders { + return .{ .allocator = allocator, .values = std.StringHashMap([]const u8).init(allocator) }; + } + + pub fn deinit(self: *RequestHeaders) void { + var it = self.values.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.value_ptr.*); + self.allocator.free(entry.key_ptr.*); + } + self.values.deinit(); + } + + pub fn put(self: *RequestHeaders, key: []const u8, value: []const u8) !void { + const key_copy = try self.allocator.dupe(u8, key); + errdefer self.allocator.free(key_copy); + for (key_copy) |*c| c.* = std.ascii.toLower(c.*); + + const value_copy = try self.allocator.dupe(u8, value); + errdefer self.allocator.free(value_copy); + + try self.values.put(key_copy, value_copy); + } + + pub fn getOrigin(self: *const RequestHeaders) ?[]const u8 { + return self.values.get("origin"); + } + + pub fn getAccessControlRequestMethod(self: *const RequestHeaders) ?[]const u8 { + return self.values.get("access-control-request-method"); + } + + pub fn getAccessControlRequestHeaders(self: *const RequestHeaders) ?[]const u8 { + return self.values.get("access-control-request-headers"); + } +}; + +/// Parse HTTP headers from a raw string +pub fn parseHeaders(allocator: Allocator, raw: []const u8) !RequestHeaders { + var headers = RequestHeaders.init(allocator); + errdefer headers.deinit(); + + var line_start: usize = 0; + while (line_start < raw.len) { + const line_end = std.mem.indexOfScalarPos(u8, raw, line_start, '\r') orelse raw.len; + const line = raw[line_start..line_end]; + if (line.len == 0) break; + + const colon = std.mem.indexOfScalar(u8, line, ':') orelse return error.InvalidHeader; + const name = std.mem.trim(u8, line[0..colon], " \t"); + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + try headers.put(name, value); + + line_start = line_end + 1; + if (line_start < raw.len and raw[line_start] == '\n') { + line_start += 1; + } + } + + return headers; +} + +fn checkOriginMatch(origin: []const u8, origins: *const Origins) OriginMatch { + switch (origins.*) { + .list => |list| { + if (list.len == 0) return .any; + for (list) |allowed| { + if (std.mem.eql(u8, origin, allowed)) return .yes; + } + return .no; + }, + .function => |func| return if (func(origin)) .yes else .no, + } +} + +pub fn isSimpleRequestHeader(header: []const u8) bool { + return std.ascii.eqlIgnoreCase(header, "Accept") or + std.ascii.eqlIgnoreCase(header, "Accept-Language") or + std.ascii.eqlIgnoreCase(header, "Content-Language") or + std.ascii.eqlIgnoreCase(header, "Content-Type") or + std.ascii.eqlIgnoreCase(header, "Range"); +} + +pub fn isSimpleContentType(content_type: []const u8) bool { + const colon = std.mem.indexOfScalar(u8, content_type, ';') orelse content_type.len; + const base_type = std.mem.trim(u8, content_type[0..colon], " \t"); + return std.ascii.eqlIgnoreCase(base_type, "application/x-www-form-urlencoded") or + std.ascii.eqlIgnoreCase(base_type, "multipart/form-data") or + std.ascii.eqlIgnoreCase(base_type, "text/plain"); +} + +pub fn isSimpleMethod(method: []const u8) bool { + return std.ascii.eqlIgnoreCase(method, "GET") or + std.ascii.eqlIgnoreCase(method, "HEAD") or + std.ascii.eqlIgnoreCase(method, "POST"); +} + +pub fn isSimpleRequest(method: []const u8, headers: *const RequestHeaders) bool { + if (!isSimpleMethod(method)) return false; + + if (std.ascii.eqlIgnoreCase(method, "POST")) { + if (headers.values.get("content-type")) |ct| { + if (!isSimpleContentType(ct)) return false; + } + } + + var it = headers.values.iterator(); + while (it.next()) |entry| { + if (!isSimpleRequestHeader(entry.key_ptr.*) and !std.ascii.eqlIgnoreCase(entry.key_ptr.*, "origin")) { + return false; + } + } + + return true; +} + +pub fn processCors( + allocator: Allocator, + options: *const CorsOptions, + method: []const u8, + headers: *const RequestHeaders, +) !CorsResult { + var result = CorsResult{ .headers = Headers.init(allocator), .vary = &.{} }; + errdefer result.deinit(allocator); + + const origin = headers.getOrigin() orelse return result; + + const origin_match = checkOriginMatch(origin, &options.origins); + if (origin_match == .no) return result; + + if (origin_match == .yes) { + const new_vary = try allocator.realloc(result.vary, result.vary.len + 1); + new_vary[new_vary.len - 1] = try allocator.dupe(u8, "Origin"); + result.vary = new_vary; + } + + if (std.ascii.eqlIgnoreCase(method, "OPTIONS")) { + const ac_request_method = headers.getAccessControlRequestMethod() orelse return result; + + var method_allowed = false; + for (options.methods) |allowed| { + if (std.ascii.eqlIgnoreCase(ac_request_method, allowed)) { + method_allowed = true; + break; + } + } + if (!method_allowed) { + if (options.end_preflight_requests) result.status = 204; + return result; + } + + if (headers.getAccessControlRequestHeaders()) |raw| { + var start: usize = 0; + while (start < raw.len) { + const comma = std.mem.indexOfScalarPos(u8, raw, start, ',') orelse raw.len; + const request_header = std.mem.trim(u8, raw[start..comma], " \t"); + + if (request_header.len > 0 and !std.ascii.eqlIgnoreCase(request_header, "origin")) { + var header_allowed = false; + for (options.request_headers) |allowed| { + if (std.ascii.eqlIgnoreCase(request_header, allowed)) { + header_allowed = true; + break; + } + } + if (!header_allowed) { + if (options.end_preflight_requests) result.status = 204; + return result; + } + } + + if (comma == raw.len) break; + start = comma + 1; + } + } + + try setCorsResponseHeaders(allocator, &result, options, origin); + try setPreflightResponseHeaders(allocator, &result, options); + + if (options.max_age) |max_age| { + var max_age_str: [16]u8 = undefined; + const max_age_formatted = try std.fmt.bufPrint(&max_age_str, "{d}", .{max_age}); + try result.headers.put(allocator, "Access-Control-Max-Age", max_age_formatted); + } + + if (options.end_preflight_requests) result.status = 204; + return result; + } + + try setCorsResponseHeaders(allocator, &result, options, origin); + + if (try buildExposeHeadersValue(allocator, options.response_headers)) |exposed_value| { + defer allocator.free(exposed_value); + try result.headers.put(allocator, "Access-Control-Expose-Headers", exposed_value); + } + + return result; +} + +fn setCorsResponseHeaders( + allocator: Allocator, + result: *CorsResult, + options: *const CorsOptions, + origin: []const u8, +) !void { + if (options.supports_credentials) { + try result.headers.put(allocator, "Access-Control-Allow-Origin", origin); + try result.headers.put(allocator, "Access-Control-Allow-Credentials", "true"); + } else { + switch (options.origins) { + .list => |origins| { + if (origins.len == 0) { + try result.headers.put(allocator, "Access-Control-Allow-Origin", "*"); + } else { + try result.headers.put(allocator, "Access-Control-Allow-Origin", origin); + } + }, + .function => { + try result.headers.put(allocator, "Access-Control-Allow-Origin", origin); + }, + } + } +} + +fn setPreflightResponseHeaders( + allocator: Allocator, + result: *CorsResult, + options: *const CorsOptions, +) !void { + const methods_value = try std.mem.join(allocator, ", ", options.methods); + defer allocator.free(methods_value); + try result.headers.put(allocator, "Access-Control-Allow-Methods", methods_value); + + const headers_value = try std.mem.join(allocator, ", ", options.request_headers); + defer allocator.free(headers_value); + try result.headers.put(allocator, "Access-Control-Allow-Headers", headers_value); +} + +fn isSimpleResponseHeader(header: []const u8) bool { + return std.ascii.eqlIgnoreCase(header, "Cache-Control") or + std.ascii.eqlIgnoreCase(header, "Content-Language") or + std.ascii.eqlIgnoreCase(header, "Content-Type") or + std.ascii.eqlIgnoreCase(header, "Expires") or + std.ascii.eqlIgnoreCase(header, "Last-Modified") or + std.ascii.eqlIgnoreCase(header, "Pragma"); +} + +fn buildExposeHeadersValue(allocator: Allocator, response_headers: []const []const u8) !?[]u8 { + var count: usize = 0; + var total_len: usize = 0; + + for (response_headers) |header| { + if (isSimpleResponseHeader(header)) continue; + if (count > 0) total_len += 2; + total_len += header.len; + count += 1; + } + + if (count == 0) return null; + + var out = try allocator.alloc(u8, total_len); + var at: usize = 0; + var written: usize = 0; + + for (response_headers) |header| { + if (isSimpleResponseHeader(header)) continue; + if (written > 0) { + out[at] = ','; + out[at + 1] = ' '; + at += 2; + } + @memcpy(out[at .. at + header.len], header); + at += header.len; + written += 1; + } + + return out; +} + +/// Validate response headers for client-side CORS checks +pub fn isResponseAllowed( + allow_origin: ?[]const u8, + allow_credentials: bool, + request_origin: []const u8, + request_credentials: bool, +) bool { + const ao = allow_origin orelse return false; + + if (request_credentials) { + if (!allow_credentials) return false; + if (std.ascii.eqlIgnoreCase(ao, "*")) return false; + return std.mem.eql(u8, ao, request_origin); + } + + if (std.ascii.eqlIgnoreCase(ao, "*")) return true; + return std.mem.eql(u8, ao, request_origin); +} + +// ============================================================================ +// Tests +// ============================================================================ + +const testing = std.testing; + +test "parseHeaders: basic headers" { + const raw = "Host: example.com\r\nContent-Type: application/json\r\n\r\n"; + var headers = try parseHeaders(testing.allocator, raw); + defer headers.deinit(); + + try testing.expectEqualStrings("example.com", headers.values.get("host").?); + try testing.expectEqualStrings("application/json", headers.values.get("content-type").?); + try testing.expect(headers.values.get("accept") == null); +} + +test "isSimpleMethod: valid methods" { + try testing.expect(isSimpleMethod("GET")); + try testing.expect(isSimpleMethod("HEAD")); + try testing.expect(isSimpleMethod("POST")); + try testing.expect(isSimpleMethod("get")); + try testing.expect(isSimpleMethod("post")); +} + +test "isSimpleContentType: valid types" { + try testing.expect(isSimpleContentType("application/x-www-form-urlencoded")); + try testing.expect(isSimpleContentType("multipart/form-data")); + try testing.expect(isSimpleContentType("text/plain")); + try testing.expect(isSimpleContentType("application/x-www-form-urlencoded; charset=utf-8")); +} + +test "isSimpleRequestHeader: valid headers" { + try testing.expect(isSimpleRequestHeader("Accept")); + try testing.expect(isSimpleRequestHeader("Accept-Language")); + try testing.expect(isSimpleRequestHeader("Content-Language")); + try testing.expect(isSimpleRequestHeader("Content-Type")); + try testing.expect(isSimpleRequestHeader("Range")); +} + +test "processCors: simple GET with wildcard origin" { + var options = CorsOptions.defaultOptions(); + defer options.deinit(testing.allocator); + + var headers = RequestHeaders.init(testing.allocator); + defer headers.deinit(); + try headers.put("origin", "https://example.com"); + + var result = try processCors(testing.allocator, &options, "GET", &headers); + defer result.deinit(testing.allocator); + + try testing.expectEqualStrings("*", result.headers.get("Access-Control-Allow-Origin").?); + try testing.expectEqual(null, result.status); +} + +test "processCors: preflight request" { + var options = CorsOptions.defaultOptions(); + options.max_age = 86400; + defer options.deinit(testing.allocator); + + var headers = RequestHeaders.init(testing.allocator); + defer headers.deinit(); + try headers.put("origin", "https://example.com"); + try headers.put("access-control-request-method", "POST"); + try headers.put("access-control-request-headers", "Content-Type"); + + var result = try processCors(testing.allocator, &options, "OPTIONS", &headers); + defer result.deinit(testing.allocator); + + try testing.expectEqual(204, result.status); + try testing.expect(result.headers.contains("Access-Control-Allow-Origin")); + try testing.expect(result.headers.contains("Access-Control-Allow-Methods")); + try testing.expect(result.headers.contains("Access-Control-Allow-Headers")); + try testing.expect(result.headers.contains("Access-Control-Max-Age")); +} + +test "isResponseAllowed: credentials" { + try testing.expect(isResponseAllowed("https://example.com", true, "https://example.com", true)); + try testing.expect(!isResponseAllowed("*", true, "https://example.com", true)); + try testing.expect(!isResponseAllowed("https://example.com", false, "https://example.com", true)); +} From f391f8564b28561e9d08c21398685214de295bcb Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Mon, 11 May 2026 21:08:52 +0600 Subject: [PATCH 02/18] integration: cors in xml-http-request --- src/browser/webapi/net/XMLHttpRequest.zig | 27 +++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 35f7f7fa35..170b03058a 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -22,6 +22,7 @@ const js = @import("../../js/js.zig"); const HttpClient = @import("../../HttpClient.zig"); const http = @import("../../../network/http.zig"); +const Cors = @import("../../../network/Cors.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); @@ -449,8 +450,19 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { }; } + self._response_url = try self._arena.dupeZ(u8, response.url()); + + var allow_origin: ?[]const u8 = null; + var allow_credentials = false; + var it = response.headerIterator(); while (it.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) { + allow_origin = hdr.value; + } else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials") and + std.ascii.eqlIgnoreCase(hdr.value, "true")) { + allow_credentials = true; + } const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); try self._response_headers.append(self._arena, joined); } @@ -460,9 +472,20 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { self._response_len = cl; try self._response_data.ensureTotalCapacity(self._arena, cl); } - self._response_url = try self._arena.dupeZ(u8, response.url()); - const frame = self._frame; + const request_origin = URL.getOrigin(self._arena, frame.url) catch null; + const response_origin = URL.getOrigin(self._arena, self._response_url) catch null; + const cross_origin = if (request_origin != null and response_origin != null) + !std.mem.eql(u8, request_origin.?, response_origin.?) + else + false; + + if (cross_origin) { + if (!Cors.isResponseAllowed(allow_origin, allow_credentials, request_origin.?, self._with_credentials)) { + response.abort(error.CorsBlocked); + return false; + } + } var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); From 09bcf53970b0dd1536149c91e7a46bc40df54bf9 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Mon, 11 May 2026 21:09:05 +0600 Subject: [PATCH 03/18] integration: cors in fetch --- src/browser/webapi/net/Fetch.zig | 41 ++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 623037d66b..866d6e1d70 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -19,6 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const HttpClient = @import("../../HttpClient.zig"); +const Cors = @import("../../../network/Cors.zig"); const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); @@ -42,6 +43,7 @@ _response: *Response, _resolver: js.PromiseResolver.Global, _owns_response: bool, _signal: ?*AbortSignal, +_allow_credentials: bool, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; @@ -73,6 +75,7 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { ._response = response, ._owns_response = true, ._signal = request._signal, + ._allow_credentials = request._credentials == .include, }; const http_client = frame._session.browser.http_client; @@ -171,29 +174,37 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { res._url = try arena.dupeZ(u8, response.url()); res._is_redirected = response.redirectCount().? > 0; - // Determine response type based on origin comparison - const frame_origin = URL.getOrigin(arena, self._frame.url) catch null; + const request_origin = URL.getOrigin(arena, self._frame.url) catch null; const response_origin = URL.getOrigin(arena, res._url) catch null; + const cross_origin = if (request_origin != null and response_origin != null) + !std.mem.eql(u8, request_origin.?, response_origin.?) + else + false; - if (frame_origin) |fo| { - if (response_origin) |ro| { - if (std.mem.eql(u8, fo, ro)) { - res._type = .basic; // Same-origin - } else { - res._type = .cors; // Cross-origin (for simplicity, assume CORS passed) - } - } else { - res._type = .basic; - } - } else { - res._type = .basic; - } + var allow_origin: ?[]const u8 = null; + var allow_credentials = false; var it = response.headerIterator(); while (it.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) { + allow_origin = hdr.value; + } else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials") and + std.ascii.eqlIgnoreCase(hdr.value, "true")) { + allow_credentials = true; + } try res._headers.append(hdr.name, hdr.value, &self._frame.js.execution); } + if (cross_origin) { + if (!Cors.isResponseAllowed(allow_origin, allow_credentials, request_origin.?, self._allow_credentials)) { + response.abort(error.CorsBlocked); + return false; + } + res._type = .cors; + } else { + res._type = .basic; + } + return true; } From aa770119826768de47481103e21ca83159f26636 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Mon, 11 May 2026 22:04:46 +0600 Subject: [PATCH 04/18] fix: cors preflight validation and vary handling Co-authored-by: Copilot --- src/network/Cors.zig | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/network/Cors.zig b/src/network/Cors.zig index 281a297c8c..e5d973842b 100644 --- a/src/network/Cors.zig +++ b/src/network/Cors.zig @@ -59,8 +59,16 @@ pub const Headers = struct { const value_copy = try allocator.dupe(u8, value); errdefer allocator.free(value_copy); - self.keys = try allocator.realloc(self.keys, self.keys.len + 1); - self.values = try allocator.realloc(self.values, self.values.len + 1); + if (self.keys.len == 0) { + const new_keys = try allocator.alloc([]u8, 1); + errdefer allocator.free(new_keys); + const new_values = try allocator.alloc([]u8, 1); + self.keys = new_keys; + self.values = new_values; + } else { + self.keys = try allocator.realloc(self.keys, self.keys.len + 1); + self.values = try allocator.realloc(self.values, self.values.len + 1); + } self.keys[self.keys.len - 1] = key_copy; self.values[self.values.len - 1] = value_copy; } @@ -182,7 +190,13 @@ pub fn parseHeaders(allocator: Allocator, raw: []const u8) !RequestHeaders { var line_start: usize = 0; while (line_start < raw.len) { - const line_end = std.mem.indexOfScalarPos(u8, raw, line_start, '\r') orelse raw.len; + const cr = std.mem.indexOfScalarPos(u8, raw, line_start, '\r'); + const lf = std.mem.indexOfScalarPos(u8, raw, line_start, '\n'); + var line_end: usize = raw.len; + if (cr) |c| line_end = c; + if (lf) |l| { + if (l < line_end) line_end = l; + } const line = raw[line_start..line_end]; if (line.len == 0) break; @@ -191,8 +205,8 @@ pub fn parseHeaders(allocator: Allocator, raw: []const u8) !RequestHeaders { const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); try headers.put(name, value); - line_start = line_end + 1; - if (line_start < raw.len and raw[line_start] == '\n') { + line_start = line_end; + while (line_start < raw.len and (raw[line_start] == '\r' or raw[line_start] == '\n')) { line_start += 1; } } @@ -268,8 +282,11 @@ pub fn processCors( const origin_match = checkOriginMatch(origin, &options.origins); if (origin_match == .no) return result; - if (origin_match == .yes) { - const new_vary = try allocator.realloc(result.vary, result.vary.len + 1); + if (origin_match != .no) { + const new_vary = if (result.vary.len == 0) + try allocator.alloc([]u8, 1) + else + try allocator.realloc(result.vary, result.vary.len + 1); new_vary[new_vary.len - 1] = try allocator.dupe(u8, "Origin"); result.vary = new_vary; } @@ -295,7 +312,10 @@ pub fn processCors( const comma = std.mem.indexOfScalarPos(u8, raw, start, ',') orelse raw.len; const request_header = std.mem.trim(u8, raw[start..comma], " \t"); - if (request_header.len > 0 and !std.ascii.eqlIgnoreCase(request_header, "origin")) { + if (request_header.len > 0 and + !std.ascii.eqlIgnoreCase(request_header, "origin") and + !isSimpleRequestHeader(request_header)) + { var header_allowed = false; for (options.request_headers) |allowed| { if (std.ascii.eqlIgnoreCase(request_header, allowed)) { From 9732adfae63eab8b213f531fa05182465627fc4a Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Mon, 11 May 2026 22:32:38 +0600 Subject: [PATCH 05/18] chore: listed cors as implemented feature --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6aeefa5685..a25c8d0e85 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ You may still encounter errors or crashes. Please open an issue with specifics i Here are the key features we have implemented: -- [ ] CORS [#2015](https://github.com/lightpanda-io/browser/issues/2015) +- [x] CORS ([CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)) - [x] HTTP loader ([Libcurl](https://curl.se/libcurl/)) - [x] HTML parser ([html5ever](https://github.com/servo/html5ever)) - [x] DOM tree From 30462fb7dbfc7eeeb101394813ec197c59265d41 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Tue, 12 May 2026 02:44:19 +0600 Subject: [PATCH 06/18] fix: resolve issues from merging main Co-authored-by: Copilot --- src/browser/webapi/net/Fetch.zig | 18 ++++-------------- src/browser/webapi/net/XMLHttpRequest.zig | 6 ++---- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 3b39d3f683..c8f7b0d4f2 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -187,8 +187,8 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { const exec = self._exec; const requesting_origin = URL.getOrigin(arena, exec.url.*) catch null; const response_origin = URL.getOrigin(arena, res._url) catch null; - const cross_origin = if (request_origin != null and response_origin != null) - !std.mem.eql(u8, request_origin.?, response_origin.?) + const cross_origin = if (requesting_origin != null and response_origin != null) + !std.mem.eql(u8, requesting_origin.?, response_origin.?) else false; @@ -203,21 +203,11 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { std.ascii.eqlIgnoreCase(hdr.value, "true")) { allow_credentials = true; } - try res._headers.append(hdr.name, hdr.value, &self._frame.js.execution); + try res._headers.append(hdr.name, hdr.value, exec); } if (cross_origin) { - if (!Cors.isResponseAllowed(allow_origin, allow_credentials, request_origin.?, self._allow_credentials)) { - response.abort(error.CorsBlocked); - return false; - } - res._type = .cors; - } else { - res._type = .basic; - } - - if (cross_origin) { - if (!Cors.isResponseAllowed(allow_origin, allow_credentials, request_origin.?, self._allow_credentials)) { + if (!Cors.isResponseAllowed(allow_origin, allow_credentials, requesting_origin.?, self._allow_credentials)) { response.abort(error.CorsBlocked); return false; } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 1942265b62..3b54b4649f 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -472,8 +472,6 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { }; } - self._response_url = try self._arena.dupeZ(u8, response.url()); - var allow_origin: ?[]const u8 = null; var allow_credentials = false; @@ -497,8 +495,8 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { self._response_url = try self._arena.dupeZ(u8, response.url()); - const frame = self._frame; - const request_origin = URL.getOrigin(self._arena, frame.url) catch null; + const exec = self._exec; + const request_origin = URL.getOrigin(self._arena, exec.url.*) catch null; const response_origin = URL.getOrigin(self._arena, self._response_url) catch null; const cross_origin = if (request_origin != null and response_origin != null) !std.mem.eql(u8, request_origin.?, response_origin.?) From 69679f4cea80b9a98eae4f6335d64e1c27eb8a01 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Tue, 12 May 2026 14:08:33 +0600 Subject: [PATCH 07/18] lint: ran `zig fmt ./` --- src/browser/webapi/net/Fetch.zig | 3 ++- src/browser/webapi/net/XMLHttpRequest.zig | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index c8f7b0d4f2..1e99b8db70 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -200,7 +200,8 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) { allow_origin = hdr.value; } else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials") and - std.ascii.eqlIgnoreCase(hdr.value, "true")) { + std.ascii.eqlIgnoreCase(hdr.value, "true")) + { allow_credentials = true; } try res._headers.append(hdr.name, hdr.value, exec); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 3b54b4649f..249a4c7c68 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -480,7 +480,8 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) { allow_origin = hdr.value; } else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials") and - std.ascii.eqlIgnoreCase(hdr.value, "true")) { + std.ascii.eqlIgnoreCase(hdr.value, "true")) + { allow_credentials = true; } const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); From 8bcca74c3114432d6e32e838da1780a8cbdcba90 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 22:55:31 +0600 Subject: [PATCH 08/18] feat: cehck if header is exposed for cors --- src/network/Cors.zig | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/network/Cors.zig b/src/network/Cors.zig index e5d973842b..4036532ec5 100644 --- a/src/network/Cors.zig +++ b/src/network/Cors.zig @@ -405,6 +405,32 @@ fn isSimpleResponseHeader(header: []const u8) bool { std.ascii.eqlIgnoreCase(header, "Pragma"); } +pub fn isResponseHeaderExposed( + header: []const u8, + expose_headers: ?[]const u8, + request_credentials: bool, +) bool { + if (isSimpleResponseHeader(header)) return true; + + const expose = expose_headers orelse return false; + var start: usize = 0; + while (start < expose.len) { + const comma = std.mem.indexOfScalarPos(u8, expose, start, ',') orelse expose.len; + const token = std.mem.trim(u8, expose[start..comma], " \t"); + if (token.len > 0) { + if (std.ascii.eqlIgnoreCase(token, "*")) { + if (!request_credentials) return true; + } else if (std.ascii.eqlIgnoreCase(token, header)) { + return true; + } + } + if (comma == expose.len) break; + start = comma + 1; + } + + return false; +} + fn buildExposeHeadersValue(allocator: Allocator, response_headers: []const []const u8) !?[]u8 { var count: usize = 0; var total_len: usize = 0; @@ -536,3 +562,23 @@ test "isResponseAllowed: credentials" { try testing.expect(!isResponseAllowed("*", true, "https://example.com", true)); try testing.expect(!isResponseAllowed("https://example.com", false, "https://example.com", true)); } + +test "isSimpleRequest: simple vs non-simple" { + var headers = RequestHeaders.init(testing.allocator); + defer headers.deinit(); + + try headers.put("origin", "https://example.com"); + try headers.put("content-type", "text/plain"); + try testing.expect(isSimpleRequest("POST", &headers)); + + try headers.put("x-custom", "1"); + try testing.expect(!isSimpleRequest("POST", &headers)); +} + +test "isResponseHeaderExposed: list and wildcard" { + try testing.expect(isResponseHeaderExposed("Content-Type", null, false)); + try testing.expect(isResponseHeaderExposed("X-Token", "X-Token", false)); + try testing.expect(!isResponseHeaderExposed("X-Other", "X-Token", false)); + try testing.expect(isResponseHeaderExposed("X-Any", "*", false)); + try testing.expect(!isResponseHeaderExposed("X-Any", "*", true)); +} From 6a307db73a5fb48352685aa8e9624636de5e2fcf Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 22:57:01 +0600 Subject: [PATCH 09/18] integration: build cors header for fetch --- src/browser/webapi/net/Fetch.zig | 62 +++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 1e99b8db70..967e8dc6dc 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -28,11 +28,13 @@ const URL = @import("../../URL.zig"); const Blob = @import("../Blob.zig"); const Request = @import("Request.zig"); const Response = @import("Response.zig"); +const Headers = @import("Headers.zig"); const AbortSignal = @import("../AbortSignal.zig"); const DOMException = @import("../DOMException.zig"); const log = lp.log; const Execution = js.Execution; +const Allocator = std.mem.Allocator; const IS_DEBUG = @import("builtin").mode == .Debug; const Fetch = @This(); @@ -49,6 +51,16 @@ _allow_credentials: bool, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; +fn buildCorsRequestHeaders(allocator: Allocator, headers: ?*Headers) !Cors.RequestHeaders { + var out = Cors.RequestHeaders.init(allocator); + if (headers) |h| { + for (h._list._entries.items) |entry| { + try out.put(entry.name.str(), entry.value.str()); + } + } + return out; +} + pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promise { const request = try Request.init(input, options, exec); const resolver = exec.context.local.?.createPromiseResolver(); @@ -64,6 +76,19 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis return handleBlobUrl(request._url, resolver, exec); } + const request_origin = URL.getOrigin(exec.call_arena, exec.url.*) catch null; + const cross_origin = request_origin != null and !exec.isSameOrigin(request._url); + + if (cross_origin) { + var cors_headers = try buildCorsRequestHeaders(exec.call_arena, request._headers); + defer cors_headers.deinit(); + + if (!Cors.isSimpleRequest(@tagName(request._method), &cors_headers)) { + resolver.rejectError("fetch error", .{ .type_error = "fetch error" }); + return resolver.promise(); + } + } + const response = try Response.init(null, .{ .status = 0 }, exec); errdefer response.deinit(exec.context.page); @@ -87,6 +112,14 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis } try exec.headersForRequest(&headers); + if (cross_origin and request_origin != null) { + const has_origin = if (request._headers) |h| h.has("origin", exec) else false; + if (!has_origin) { + const origin_header = try std.fmt.allocPrintSentinel(exec.call_arena, "Origin: {s}", .{request_origin.?}, 0); + try headers.add(origin_header); + } + } + if (comptime IS_DEBUG) { log.debug(.http, "fetch", .{ .url = request._url }); } @@ -194,17 +227,30 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { var allow_origin: ?[]const u8 = null; var allow_credentials = false; + var expose_headers: ?[]const u8 = null; + + const RawHeader = struct { + name: []const u8, + value: []const u8, + }; + var header_list: std.ArrayList(RawHeader) = .empty; var it = response.headerIterator(); while (it.next()) |hdr| { - if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) { - allow_origin = hdr.value; - } else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials") and - std.ascii.eqlIgnoreCase(hdr.value, "true")) + const name = try arena.dupe(u8, hdr.name); + const value = try arena.dupe(u8, hdr.value); + const value_trimmed = std.mem.trim(u8, value, " \t"); + try header_list.append(arena, .{ .name = name, .value = value }); + + if (std.ascii.eqlIgnoreCase(name, "access-control-allow-origin")) { + allow_origin = value_trimmed; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-credentials") and + std.ascii.eqlIgnoreCase(value_trimmed, "true")) { allow_credentials = true; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-expose-headers")) { + expose_headers = value_trimmed; } - try res._headers.append(hdr.name, hdr.value, exec); } if (cross_origin) { @@ -217,6 +263,12 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { res._type = .basic; } + for (header_list.items) |hdr| { + if (!cross_origin or Cors.isResponseHeaderExposed(hdr.name, expose_headers, self._allow_credentials)) { + try res._headers.append(hdr.name, hdr.value, exec); + } + } + return true; } From 950b714bb8ec1496c53c298eca11ccb914e0acaf Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 22:57:37 +0600 Subject: [PATCH 10/18] integration: build cors header for xml http request --- src/browser/webapi/net/XMLHttpRequest.zig | 66 ++++++++++++++++++++--- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 249a4c7c68..7429d30d9e 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -69,6 +69,14 @@ _on_ready_state_change: ?js.Function.Temp = null, _with_credentials: bool = false, _timeout: u32 = 0, +fn buildCorsRequestHeaders(allocator: Allocator, headers: *Headers) !Cors.RequestHeaders { + var out = Cors.RequestHeaders.init(allocator); + for (headers._list._entries.items) |entry| { + try out.put(entry.name.str(), entry.value.str()); + } + return out; +} + const ReadyState = enum(u8) { unsent = 0, opened = 1, @@ -254,6 +262,19 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v return self.handleBlobUrl(exec); } + const request_origin = URL.getOrigin(self._arena, exec.url.*) catch null; + const cross_origin = request_origin != null and !exec.isSameOrigin(self._url); + + if (cross_origin) { + var cors_headers = try buildCorsRequestHeaders(self._arena, self._request_headers); + defer cors_headers.deinit(); + + if (!Cors.isSimpleRequest(@tagName(self._method), &cors_headers)) { + self.handleError(error.CorsBlocked); + return; + } + } + const session = exec.context.page.session; const http_client = &session.browser.http_client; var headers = try http_client.newHeaders(); @@ -266,6 +287,14 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v try exec.headersForRequest(&headers); } + if (cross_origin and request_origin != null) { + const has_origin = self._request_headers.has("origin", exec); + if (!has_origin) { + const origin_header = try std.fmt.allocPrintSentinel(exec.call_arena, "Origin: {s}", .{request_origin.?}, 0); + try headers.add(origin_header); + } + } + self.acquireRef(); self._active_request = true; @@ -474,18 +503,30 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { var allow_origin: ?[]const u8 = null; var allow_credentials = false; + var expose_headers: ?[]const u8 = null; + + const RawHeader = struct { + name: []const u8, + value: []const u8, + }; + var header_list: std.ArrayList(RawHeader) = .empty; var it = response.headerIterator(); while (it.next()) |hdr| { - if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-origin")) { - allow_origin = hdr.value; - } else if (std.ascii.eqlIgnoreCase(hdr.name, "access-control-allow-credentials") and - std.ascii.eqlIgnoreCase(hdr.value, "true")) + const name = try self._arena.dupe(u8, hdr.name); + const value = try self._arena.dupe(u8, hdr.value); + const value_trimmed = std.mem.trim(u8, value, " \t"); + try header_list.append(self._arena, .{ .name = name, .value = value }); + + if (std.ascii.eqlIgnoreCase(name, "access-control-allow-origin")) { + allow_origin = value_trimmed; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-credentials") and + std.ascii.eqlIgnoreCase(value_trimmed, "true")) { allow_credentials = true; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-expose-headers")) { + expose_headers = value_trimmed; } - const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); - try self._response_headers.append(self._arena, joined); } self._response_status = response.status().?; @@ -506,11 +547,24 @@ fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { if (cross_origin) { if (!Cors.isResponseAllowed(allow_origin, allow_credentials, request_origin.?, self._with_credentials)) { + self._response_status = 0; + self._response_len = 0; + self._response_url = ""; + self._response_mime = null; + self._response_headers.clearRetainingCapacity(); + self._response_data.clearRetainingCapacity(); response.abort(error.CorsBlocked); return false; } } + for (header_list.items) |hdr| { + if (!cross_origin or Cors.isResponseHeaderExposed(hdr.name, expose_headers, self._with_credentials)) { + const joined = try std.fmt.allocPrint(self._arena, "{s}: {s}", .{ hdr.name, hdr.value }); + try self._response_headers.append(self._arena, joined); + } + } + var ls: js.Local.Scope = undefined; exec.context.localScope(&ls); defer ls.deinit(); From 81527cd61f5e0b6c9dc4b25787d563b25e7f70fc Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 23:12:54 +0600 Subject: [PATCH 11/18] test: setup for cors pass and failing --- src/testing.zig | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/testing.zig b/src/testing.zig index 9e59ebc0e7..71aa830b78 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -47,6 +47,7 @@ const Frame = @import("browser/Frame.zig"); const Browser = @import("browser/Browser.zig"); const Session = @import("browser/Session.zig"); const Notification = @import("Notification.zig"); +const Cors = @import("network/Cors.zig"); // Merged std.testing.expectEqual and std.testing.expectString // can be useful when testing fields of an anytype an you don't know @@ -566,6 +567,33 @@ test "tests:afterAll" { test_config.deinit(@import("root").tracking_allocator); } +fn buildCorsRequestHeaders(test_allocator: Allocator, req: *std.http.Server.Request) !Cors.RequestHeaders { + var aw: std.Io.Writer.Allocating = .init(test_allocator); + defer aw.deinit(); + var it = req.iterateHeaders(); + while (it.next()) |h| { + try aw.writer.print("{s}: {s}\r\n", .{ h.name, h.value }); + } + try aw.writer.writeAll("\r\n"); + + return Cors.parseHeaders(test_allocator, aw.written()); +} + +fn appendCorsResponseHeaders( + test_allocator: Allocator, + list: *std.ArrayList(std.http.Header), + result: *const Cors.CorsResult, +) !void { + for (result.headers.keys, 0..) |key, i| { + try list.append(test_allocator, .{ .name = key, .value = result.headers.values[i] }); + } + + if (result.vary.len > 0) { + const vary_value = try std.mem.join(test_allocator, ", ", result.vary); + try list.append(test_allocator, .{ .name = "Vary", .value = vary_value }); + } +} + fn serveCDP(wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9583); @@ -666,6 +694,84 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/cors/ok")) { + var cors_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer cors_arena.deinit(); + const cors_allocator = cors_arena.allocator(); + + var cors_options = Cors.CorsOptions.defaultOptions(); + cors_options.request_headers = &.{ "Accept", "Accept-Language", "Content-Language", "Content-Type", "Range", "X-Custom-Header" }; + cors_options.response_headers = &.{ "X-Test-Header" }; + defer cors_options.deinit(cors_allocator); + + var req_headers = try buildCorsRequestHeaders(cors_allocator, req); + defer req_headers.deinit(); + + var cors_result = try Cors.processCors(cors_allocator, &cors_options, @tagName(req.head.method), &req_headers); + defer cors_result.deinit(cors_allocator); + + var cors_only: std.ArrayList(std.http.Header) = .empty; + try appendCorsResponseHeaders(cors_allocator, &cors_only, &cors_result); + + if (req.head.method == .OPTIONS and cors_result.status != null) { + return req.respond("", .{ + .status = @enumFromInt(cors_result.status.?), + .extra_headers = cors_only.items, + }); + } + + var extra: std.ArrayList(std.http.Header) = .empty; + try extra.append(cors_allocator, .{ .name = "Content-Type", .value = "text/plain" }); + try extra.append(cors_allocator, .{ .name = "X-Test-Header", .value = "allowed" }); + try extra.append(cors_allocator, .{ .name = "X-Blocked", .value = "blocked" }); + try extra.appendSlice(cors_allocator, cors_only.items); + + return req.respond("cors-ok", .{ .extra_headers = extra.items }); + } + + if (std.mem.eql(u8, path, "/cors/cred")) { + var cors_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer cors_arena.deinit(); + const cors_allocator = cors_arena.allocator(); + + var cors_options = Cors.CorsOptions.defaultOptions(); + cors_options.request_headers = &.{ "Accept", "Accept-Language", "Content-Language", "Content-Type", "Range", "X-Custom-Header" }; + cors_options.response_headers = &.{ "X-Test-Header" }; + cors_options.supports_credentials = true; + defer cors_options.deinit(cors_allocator); + + var req_headers = try buildCorsRequestHeaders(cors_allocator, req); + defer req_headers.deinit(); + + var cors_result = try Cors.processCors(cors_allocator, &cors_options, @tagName(req.head.method), &req_headers); + defer cors_result.deinit(cors_allocator); + + var cors_only: std.ArrayList(std.http.Header) = .empty; + try appendCorsResponseHeaders(cors_allocator, &cors_only, &cors_result); + + if (req.head.method == .OPTIONS and cors_result.status != null) { + return req.respond("", .{ + .status = @enumFromInt(cors_result.status.?), + .extra_headers = cors_only.items, + }); + } + + var extra: std.ArrayList(std.http.Header) = .empty; + try extra.append(cors_allocator, .{ .name = "Content-Type", .value = "text/plain" }); + try extra.append(cors_allocator, .{ .name = "X-Test-Header", .value = "allowed" }); + try extra.appendSlice(cors_allocator, cors_only.items); + + return req.respond("cors-cred", .{ .extra_headers = extra.items }); + } + + if (std.mem.eql(u8, path, "/cors/block")) { + return req.respond("cors-block", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/plain" }, + }, + }); + } + if (std.mem.eql(u8, path, "/echo_referer")) { // Echo the request's Referer header back as HTML so tests can assert // what Referer the navigation sent. Used by the cross-page Referer test. From 040027451e735f36bc6a0c4a10afa162b9eb9403 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 23:13:09 +0600 Subject: [PATCH 12/18] test: cors for fetch --- src/browser/tests/net/fetch.html | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index 525a0c8811..11f50c5b05 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -330,3 +330,84 @@ }); } + + + + + + + + From 11fc7496a674511ad1ddede65fe576c642edee15 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 23:13:16 +0600 Subject: [PATCH 13/18] test: cors for xhr --- src/browser/tests/net/xhr.html | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index 62780f971a..c8aeb8d2e4 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -386,3 +386,76 @@ }); } + + + + + + + + From 26fce96741ee22f39e4b159b06ceda308524fd6b Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Wed, 10 Jun 2026 23:17:06 +0600 Subject: [PATCH 14/18] lint: ran `zig fmt ./` --- src/testing.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testing.zig b/src/testing.zig index 71aa830b78..2cffb82149 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -701,7 +701,7 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { var cors_options = Cors.CorsOptions.defaultOptions(); cors_options.request_headers = &.{ "Accept", "Accept-Language", "Content-Language", "Content-Type", "Range", "X-Custom-Header" }; - cors_options.response_headers = &.{ "X-Test-Header" }; + cors_options.response_headers = &.{"X-Test-Header"}; defer cors_options.deinit(cors_allocator); var req_headers = try buildCorsRequestHeaders(cors_allocator, req); @@ -736,7 +736,7 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { var cors_options = Cors.CorsOptions.defaultOptions(); cors_options.request_headers = &.{ "Accept", "Accept-Language", "Content-Language", "Content-Type", "Range", "X-Custom-Header" }; - cors_options.response_headers = &.{ "X-Test-Header" }; + cors_options.response_headers = &.{"X-Test-Header"}; cors_options.supports_credentials = true; defer cors_options.deinit(cors_allocator); From 36cf969d6900e6e1e9a9ad114458be34bac18596 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Thu, 11 Jun 2026 00:36:26 +0600 Subject: [PATCH 15/18] feat: build pre flight header for cors --- src/network/Cors.zig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/network/Cors.zig b/src/network/Cors.zig index 4036532ec5..805ba40bf2 100644 --- a/src/network/Cors.zig +++ b/src/network/Cors.zig @@ -268,6 +268,31 @@ pub fn isSimpleRequest(method: []const u8, headers: *const RequestHeaders) bool return true; } +pub fn buildPreflightHeaders(allocator: Allocator, method: []const u8, headers: *const RequestHeaders) !RequestHeaders { + var out = RequestHeaders.init(allocator); + errdefer out.deinit(); + + try out.put("Access-Control-Request-Method", method); + + var custom_headers: std.ArrayList([]const u8) = .empty; + defer custom_headers.deinit(); + + var it = headers.values.iterator(); + while (it.next()) |entry| { + if (!isSimpleRequestHeader(entry.key_ptr.*) and !std.ascii.eqlIgnoreCase(entry.key_ptr.*, "origin")) { + try custom_headers.append(allocator, entry.key_ptr.*); + } + } + + if (custom_headers.items.len > 0) { + const joined = try std.mem.join(allocator, ", ", custom_headers.items); + defer allocator.free(joined); + try out.put("Access-Control-Request-Headers", joined); + } + + return out; +} + pub fn processCors( allocator: Allocator, options: *const CorsOptions, From 2c543dc7726f19deaf95640112e6acd6428f78a6 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Thu, 11 Jun 2026 00:49:26 +0600 Subject: [PATCH 16/18] fix: cors build issue --- src/browser/webapi/net/Request.zig | 2 +- src/network/Cors.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index c6e1f5ea5d..f58cc23ef3 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -56,7 +56,7 @@ pub const InitOpts = struct { signal: ?*AbortSignal = null, }; -const Credentials = enum { +pub const Credentials = enum { omit, include, @"same-origin", diff --git a/src/network/Cors.zig b/src/network/Cors.zig index 805ba40bf2..4afbfc9ed9 100644 --- a/src/network/Cors.zig +++ b/src/network/Cors.zig @@ -275,7 +275,7 @@ pub fn buildPreflightHeaders(allocator: Allocator, method: []const u8, headers: try out.put("Access-Control-Request-Method", method); var custom_headers: std.ArrayList([]const u8) = .empty; - defer custom_headers.deinit(); + defer custom_headers.deinit(allocator); var it = headers.values.iterator(); while (it.next()) |entry| { From 0e251efbdc0c505c31b661e7ce4d1c15e92a28d9 Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Thu, 11 Jun 2026 00:50:14 +0600 Subject: [PATCH 17/18] integration: preflight request for cors in xml http request --- src/browser/webapi/net/XMLHttpRequest.zig | 152 +++++++++++++++++++++- 1 file changed, 149 insertions(+), 3 deletions(-) diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index be03b06c13..60af9ab1c5 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -257,6 +257,8 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v } const exec = self._exec; + const session = exec.context.page.session; + const http_client = &session.browser.http_client; if (std.mem.startsWith(u8, self._url, "blob:")) { return self.handleBlobUrl(exec); @@ -270,13 +272,53 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v defer cors_headers.deinit(); if (!Cors.isSimpleRequest(@tagName(self._method), &cors_headers)) { - self.handleError(error.CorsBlocked); + var preflight_headers = try Cors.buildPreflightHeaders(self._arena, @tagName(self._method), &cors_headers); + defer preflight_headers.deinit(); + + var preflight_http_headers = try http_client.newHeaders(); + defer preflight_http_headers.deinit(); + + if (request_origin != null) { + const origin_header = try std.fmt.allocPrintSentinel(self._arena, "Origin: {s}", .{request_origin.?}, 0); + try preflight_http_headers.add(origin_header); + } + + var preflight_it = preflight_headers.values.iterator(); + while (preflight_it.next()) |entry| { + const hdr = try std.fmt.allocPrintSentinel(self._arena, "{s}: {s}", .{ entry.key_ptr.*, entry.value_ptr.* }, 0); + try preflight_http_headers.add(hdr); + } + + self.acquireRef(); + self._active_request = true; + + exec.makeRequest(.{ + .ctx = self, + .params = .{ + .url = self._url, + .method = .OPTIONS, + .headers = preflight_http_headers, + .frame_id = exec.frameId(), + .loader_id = exec.loaderId(), + .cookie_jar = null, + .cookie_origin = exec.url.*, + .resource_type = .xhr, + .notification = session.notification, + }, + .start_callback = httpStartCallback, + .header_callback = preflightHeaderDoneCallback, + .data_callback = httpDataCallback, + .done_callback = preflightDoneCallback, + .error_callback = httpErrorCallback, + .shutdown_callback = httpShutdownCallback, + }) catch |err| { + self.releaseSelfRef(); + return err; + }; return; } } - const session = exec.context.page.session; - const http_client = &session.browser.http_client; var headers = try http_client.newHeaders(); // Only add cookies for same-origin or when withCredentials is true @@ -479,6 +521,110 @@ fn httpHeaderCallback(response: HttpClient.Response, header: http.Header) !void try self._response_headers.append(self._arena, joined); } +fn preflightHeaderDoneCallback(response: HttpClient.Response) !bool { + const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx)); + + if (comptime IS_DEBUG) { + log.debug(.http, "preflight header", .{ + .source = "xhr", + .url = self._url, + .status = response.status(), + }); + } + + var allow_origin: ?[]const u8 = null; + var allow_credentials = false; + var allow_methods: ?[]const u8 = null; + var allow_headers: ?[]const u8 = null; + + var it = response.headerIterator(); + while (it.next()) |hdr| { + const name = try self._arena.dupe(u8, hdr.name); + const value = try self._arena.dupe(u8, hdr.value); + const value_trimmed = std.mem.trim(u8, value, " \t"); + + if (std.ascii.eqlIgnoreCase(name, "access-control-allow-origin")) { + allow_origin = value_trimmed; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-credentials") and + std.ascii.eqlIgnoreCase(value_trimmed, "true")) + { + allow_credentials = true; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-methods")) { + allow_methods = value_trimmed; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-headers")) { + allow_headers = value_trimmed; + } + } + + const exec = self._exec; + const request_origin = URL.getOrigin(self._arena, exec.url.*) catch null; + + if (!Cors.isResponseAllowed(allow_origin, allow_credentials, request_origin.?, self._with_credentials)) { + response.abort(error.CorsBlocked); + return false; + } + + // TODO: validate allow_methods and allow_headers against the actual request + + return true; +} + +fn preflightDoneCallback(ctx: *anyopaque) !void { + const self: *XMLHttpRequest = @ptrCast(@alignCast(ctx)); + + log.info(.http, "preflight complete", .{ + .source = "xhr", + .url = self._url, + }); + + const exec = self._exec; + const session = exec.context.page.session; + const http_client = &session.browser.http_client; + var headers = try http_client.newHeaders(); + defer headers.deinit(); + + try self._request_headers.populateHttpHeader(exec.call_arena, &headers); + const cookie_support = self._with_credentials or exec.isSameOrigin(self._url); + if (cookie_support) { + try exec.headersForRequest(&headers); + } + + const request_origin = URL.getOrigin(self._arena, exec.url.*) catch null; + if (request_origin != null) { + const has_origin = self._request_headers.has("origin", exec); + if (!has_origin) { + const origin_header = try std.fmt.allocPrintSentinel(exec.call_arena, "Origin: {s}", .{request_origin.?}, 0); + try headers.add(origin_header); + } + } + + exec.makeRequest(.{ + .ctx = self, + .params = .{ + .url = self._url, + .method = self._method, + .headers = headers, + .frame_id = exec.frameId(), + .loader_id = exec.loaderId(), + .body = self._request_body, + .cookie_jar = if (cookie_support) &session.cookie_jar else null, + .cookie_origin = exec.url.*, + .resource_type = .xhr, + .timeout_ms = self._timeout, + .notification = session.notification, + }, + .start_callback = httpStartCallback, + .header_callback = httpHeaderDoneCallback, + .data_callback = httpDataCallback, + .done_callback = httpDoneCallback, + .error_callback = httpErrorCallback, + .shutdown_callback = httpShutdownCallback, + }) catch |err| { + self.releaseSelfRef(); + return err; + }; +} + fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { const self: *XMLHttpRequest = @ptrCast(@alignCast(response.ctx)); From 2abac181730828569a1f271679ecbe550299fc7a Mon Sep 17 00:00:00 2001 From: Maifee Ul Asad Date: Thu, 11 Jun 2026 00:50:23 +0600 Subject: [PATCH 18/18] integration: preflight request for cors in fetch --- src/browser/webapi/net/Fetch.zig | 166 +++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 10 deletions(-) diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 31f5a82c96..f620dc2ac4 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -19,6 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const HttpClient = @import("../../HttpClient.zig"); +const http = @import("../../../network/http.zig"); const Cors = @import("../../../network/Cors.zig"); const js = @import("../../js/js.zig"); @@ -47,6 +48,9 @@ _resolver: js.PromiseResolver.Global, _owns_response: bool, _signal: ?*AbortSignal, _allow_credentials: bool, +_method: http.Method, +_body: ?[]const u8, +_credentials: Request.Credentials, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; @@ -79,16 +83,6 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis const request_origin = URL.getOrigin(exec.call_arena, exec.url.*) catch null; const cross_origin = request_origin != null and !exec.isSameOrigin(request._url); - if (cross_origin) { - var cors_headers = try buildCorsRequestHeaders(exec.call_arena, request._headers); - defer cors_headers.deinit(); - - if (!Cors.isSimpleRequest(@tagName(request._method), &cors_headers)) { - resolver.rejectError("fetch error", .{ .type_error = "fetch error" }); - return resolver.promise(); - } - } - const response = try Response.init(null, .{ .status = 0 }, exec); errdefer response.deinit(exec.context.page); @@ -102,10 +96,62 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis ._owns_response = true, ._signal = request._signal, ._allow_credentials = request._credentials == .include, + ._method = request._method, + ._body = request._body, + ._credentials = request._credentials, }; const session = exec.context.page.session; const http_client = &session.browser.http_client; + + if (cross_origin) { + var cors_headers = try buildCorsRequestHeaders(exec.call_arena, request._headers); + defer cors_headers.deinit(); + + if (!Cors.isSimpleRequest(@tagName(request._method), &cors_headers)) { + var preflight_headers = try Cors.buildPreflightHeaders(exec.call_arena, @tagName(request._method), &cors_headers); + defer preflight_headers.deinit(); + + var preflight_http_headers = try http_client.newHeaders(); + defer preflight_http_headers.deinit(); + + if (request_origin != null) { + const origin_header = try std.fmt.allocPrintSentinel(exec.call_arena, "Origin: {s}", .{request_origin.?}, 0); + try preflight_http_headers.add(origin_header); + } + + var preflight_it = preflight_headers.values.iterator(); + while (preflight_it.next()) |entry| { + const hdr = try std.fmt.allocPrintSentinel(exec.call_arena, "{s}: {s}", .{ entry.key_ptr.*, entry.value_ptr.* }, 0); + try preflight_http_headers.add(hdr); + } + + const preflight_req = HttpClient.Request{ + .ctx = fetch, + .params = .{ + .url = request._url, + .method = .OPTIONS, + .frame_id = exec.frameId(), + .loader_id = exec.loaderId(), + .headers = preflight_http_headers, + .resource_type = .fetch, + .cookie_jar = null, + .cookie_origin = exec.url.*, + .notification = session.notification, + }, + .start_callback = httpStartCallback, + .header_callback = preflightHeaderDoneCallback, + .data_callback = httpDataCallback, + .done_callback = preflightDoneCallback, + .error_callback = httpErrorCallback, + .shutdown_callback = httpShutdownCallback, + }; + + exec.makeRequest(preflight_req) catch {}; + return resolver.promise(); + } + } + var headers = try http_client.newHeaders(); if (request._headers) |h| { try h.populateHttpHeader(exec.call_arena, &headers); @@ -187,6 +233,106 @@ fn httpStartCallback(response: HttpClient.Response) !void { self._response._http_response = response; } +fn preflightHeaderDoneCallback(response: HttpClient.Response) !bool { + const self: *Fetch = @ptrCast(@alignCast(response.ctx)); + + if (self._signal) |signal| { + if (signal._aborted) { + return false; + } + } + + const arena = self._response._arena; + var allow_origin: ?[]const u8 = null; + var allow_credentials = false; + var allow_methods: ?[]const u8 = null; + var allow_headers: ?[]const u8 = null; + + var it = response.headerIterator(); + while (it.next()) |hdr| { + const name = try arena.dupe(u8, hdr.name); + const value = try arena.dupe(u8, hdr.value); + const value_trimmed = std.mem.trim(u8, value, " \t"); + + if (std.ascii.eqlIgnoreCase(name, "access-control-allow-origin")) { + allow_origin = value_trimmed; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-credentials") and + std.ascii.eqlIgnoreCase(value_trimmed, "true")) + { + allow_credentials = true; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-methods")) { + allow_methods = value_trimmed; + } else if (std.ascii.eqlIgnoreCase(name, "access-control-allow-headers")) { + allow_headers = value_trimmed; + } + } + + const exec = self._exec; + const requesting_origin = URL.getOrigin(arena, exec.url.*) catch null; + + if (!Cors.isResponseAllowed(allow_origin, allow_credentials, requesting_origin.?, self._allow_credentials)) { + response.abort(error.CorsBlocked); + return false; + } + + // TODO: validate allow_methods and allow_headers against the actual request + + return true; +} + +fn preflightDoneCallback(ctx: *anyopaque) !void { + const self: *Fetch = @ptrCast(@alignCast(ctx)); + + log.info(.http, "preflight complete", .{ + .source = "fetch", + .url = self._url, + }); + + const exec = self._exec; + const session = exec.context.page.session; + const http_client = &session.browser.http_client; + var headers = try http_client.newHeaders(); + try self._response._headers.populateHttpHeader(exec.call_arena, &headers); + try exec.headersForRequest(&headers); + + const request_origin = URL.getOrigin(exec.call_arena, exec.url.*) catch null; + if (request_origin != null) { + const has_origin = self._response._headers.has("origin", exec); + if (!has_origin) { + const origin_header = try std.fmt.allocPrintSentinel(exec.call_arena, "Origin: {s}", .{request_origin.?}, 0); + try headers.add(origin_header); + } + } + + const cookie_jar = switch (self._credentials) { + .omit => null, + .include => &session.cookie_jar, + .@"same-origin" => if (exec.isSameOrigin(try exec.call_arena.dupeZ(u8, self._url))) &session.cookie_jar else null, + }; + + exec.makeRequest(.{ + .ctx = self, + .params = .{ + .url = try exec.call_arena.dupeZ(u8, self._url), + .method = self._method, + .frame_id = exec.frameId(), + .loader_id = exec.loaderId(), + .body = self._body, + .headers = headers, + .resource_type = .fetch, + .cookie_jar = cookie_jar, + .cookie_origin = exec.url.*, + .notification = session.notification, + }, + .start_callback = httpStartCallback, + .header_callback = httpHeaderDoneCallback, + .data_callback = httpDataCallback, + .done_callback = httpDoneCallback, + .error_callback = httpErrorCallback, + .shutdown_callback = httpShutdownCallback, + }) catch {}; +} + fn httpHeaderDoneCallback(response: HttpClient.Response) !bool { const self: *Fetch = @ptrCast(@alignCast(response.ctx));