From 12d3880d20889327cf2fb1762844e0e627593b04 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 25 Sep 2026 17:42:29 +0200 Subject: [PATCH 1/5] enable cors by default keep `--experimental-features cors` available for BC. add `--disable-cors` option. --- .github/workflows/e2e-integration-test.yml | 2 +- .github/workflows/e2e-test.yml | 10 ++++------ README.md | 2 +- src/Config.zig | 10 +++++++++- src/cli.zig | 2 ++ src/help.zon | 7 +++---- src/network/HttpClient.zig | 2 +- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml index 8999ce2ee..5a52a23cd 100644 --- a/.github/workflows/e2e-integration-test.yml +++ b/.github/workflows/e2e-integration-test.yml @@ -69,7 +69,7 @@ jobs: - name: run end to end integration tests continue-on-error: true run: | - ./lightpanda serve --experimental-features cors --http-cache-dir /tmp/lp-cache --http-proxy ${{ secrets.MASSIVE_PROXY_RESIDENTIAL_US }} --log-level error & echo $! > LPD.pid + ./lightpanda serve --http-cache-dir /tmp/lp-cache --http-proxy ${{ secrets.MASSIVE_PROXY_RESIDENTIAL_US }} --log-level error & echo $! > LPD.pid go run integration/main.go |tee result.log kill `cat LPD.pid` diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index f0c3f1810..9c06217a1 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -117,7 +117,7 @@ jobs: echo "value=$args" >> "$GITHUB_OUTPUT" - run: | - ./lightpanda serve --experimental-features cors --port 9222 ${{ steps.args.outputs.value }} & + ./lightpanda serve --port 9222 ${{ steps.args.outputs.value }} & - run: | go run runner/main.go @@ -163,7 +163,7 @@ jobs: - id: args name: build LP args run: | - args="--experimental-features cors --http-cache-dir /tmp/lp-cache --load-resources worker --load-resources iframe" + args="--http-cache-dir /tmp/lp-cache --load-resources worker --load-resources iframe" args="$args --protocol cdp --protocol webdriver" [ "${{ matrix.robotstxt }}" = "true" ] && args="$args --obey-robots" [ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-key-file private_key.pem" @@ -227,7 +227,6 @@ jobs: exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" ./lightpanda fetch --dump http://127.0.0.1:8989/ \ - --experimental-features cors \ --web-bot-auth-key-file /proc/self/fd/3 \ --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \ --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} @@ -251,7 +250,6 @@ jobs: exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" ./lightpanda fetch --dump "http://127.0.0.1:8989/redirect?to=http://127.0.0.1:8990/" \ - --experimental-features cors \ --web-bot-auth-key-file /proc/self/fd/3 \ --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \ --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} @@ -430,7 +428,7 @@ jobs: - run: chmod a+x ./lightpanda - - run: ./lightpanda fetch --experimental-features cors "https://demo-browser.lightpanda.io/campfire-commerce/" + - run: ./lightpanda fetch "https://demo-browser.lightpanda.io/campfire-commerce/" agent-deterministic: name: agent deterministic replay @@ -483,7 +481,7 @@ jobs: jq --version - timeout 30 ./lightpanda mcp --experimental-features cors > mcp.out <<'JSONRPC' + timeout 30 ./lightpanda mcp > mcp.out <<'JSONRPC' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1.0.0"}}} {"jsonrpc":"2.0","method":"notifications/initialized"} {"jsonrpc":"2.0","id":2,"method":"tools/list"} diff --git a/README.md b/README.md index b315d50db..b7756a825 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Set `LIGHTPANDA_DISABLE_CORE_DUMP` (to any value) to suppress crash core dumps b Here are the key features we have implemented. For full details, see our [Web Platform Tests results](https://perf.lightpanda.io/wpt). -- [x] CORS (enable with `--experimental-features cors`) +- [x] CORS - [x] HTTP loader ([Libcurl](https://curl.se/libcurl/)) - [x] HTML parser ([html5ever](https://github.com/servo/html5ever)) - [x] DOM tree diff --git a/src/Config.zig b/src/Config.zig index e6b16d37e..556baf9f0 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -239,7 +239,7 @@ pub const LoadResources = packed struct(u4) { }; pub const ExperimentalFeatures = packed struct(u2) { - cors: bool = false, + cors: bool = false, // ignored, kept only for backward compatibility. serviceworker: bool = false, }; @@ -248,6 +248,7 @@ const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, .{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000 }, .{ .name = "cors_store_entry_limit", .type = ?u32, .default = 1000 }, + .{ .name = "disable_cors", .type = bool, .default = false }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, @@ -552,6 +553,13 @@ pub fn tlsVerifyHost(self: *const Config) bool { }; } +pub fn obeyCors(self: *const Config) bool { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_cors == false, + else => unreachable, + }; +} + pub fn obeyRobots(self: *const Config) bool { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.obey_robots, diff --git a/src/cli.zig b/src/cli.zig index 881644427..47d0414c2 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -766,6 +766,8 @@ pub fn Builder(comptime commands: anytype) type { break :blk command.options; }; + // toKebabCase walks every byte of every name. + @setEvalBranchQuota(50_000); iter_args: while (args.next()) |option_name| { inline for (options) |option| { const name = option.name; diff --git a/src/help.zon b/src/help.zon index 91f19b734..bbb000d08 100644 --- a/src/help.zon +++ b/src/help.zon @@ -409,14 +409,13 @@ \\ --cors-store-entry-limit \\ Maximum number of entries kept in the CorsStore. 0 means no limit. \\ Defaults to 1000. + \\ --disable-cors + \\ Skip CORS checks on fetch/XHR requests. + \\ Defaults to false. \\ --experimental-features \\ Enable an experimental, unstable feature. Can be passed multiple times. \\ Behavior may change or be removed without notice. \\ Defaults to none enabled. - \\ Allowed values: - \\ cors Obey CORS (cross-origin resource sharing) checks - \\ on fetch/XHR requests instead of allowing them - \\ unconditionally. \\ --load-resources \\ Sub-resource to actually request. Can be passed multiple times. \\ Defaults to requesting none of them. diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index decf406a4..0ba5cd4ae 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -244,7 +244,7 @@ pub fn init(self: *Client, app: *lp.App) !void { .serve_mode = config.mode == .serve, .obey_robots = config.obeyRobots(), .http_version = config.httpVersion(), - .obey_cors = config.experimentalFeatures().cors, + .obey_cors = config.obeyCors(), .robots = .{ .network = network, .single_flight = .init(allocator), From 8d6ebf0635a21080b9439edc5465782e863cd17f Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sun, 27 Sep 2026 09:53:37 +0200 Subject: [PATCH 2/5] replace --disable-cors by --disable-features cors --- src/Config.zig | 8 ++++++-- src/help.zon | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 556baf9f0..0bb03abdb 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -243,12 +243,15 @@ pub const ExperimentalFeatures = packed struct(u2) { serviceworker: bool = false, }; +pub const DisabledFeatures = packed struct(u1) { + cors: bool = false, +}; + /// Common CLI args. const CommonOptions = .{ .{ .name = "obey_robots", .type = bool }, .{ .name = "robot_store_entry_limit", .type = ?u32, .default = 1000 }, .{ .name = "cors_store_entry_limit", .type = ?u32, .default = 1000 }, - .{ .name = "disable_cors", .type = bool, .default = false }, .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, .{ .name = "http_proxy", .type = ?[:0]const u8 }, .{ .name = "http_max_concurrent", .type = ?u8 }, @@ -284,6 +287,7 @@ const CommonOptions = .{ .{ .name = "disable_subframes", .type = bool, .deprecated = "subframes are now disabled by default, use \"--load-resources iframe\" to enable" }, .{ .name = "disable_workers", .type = bool, .deprecated = "workers are now disabled by default, use \"--load-resources worker\" to enable" }, .{ .name = "enable_external_stylesheets", .type = bool, .deprecated = "use \"--load-resources stylesheet\" to enable" }, + .{ .name = "disable_features", .type = DisabledFeatures, .default = DisabledFeatures{} }, .{ .name = "experimental_features", .type = ExperimentalFeatures, .default = ExperimentalFeatures{} }, .{ .name = "load_resources", .type = LoadResources, .default = LoadResources{} }, .{ .name = "v8_flags_unsafe", .type = ?[]const u8 }, @@ -555,7 +559,7 @@ pub fn tlsVerifyHost(self: *const Config) bool { pub fn obeyCors(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_cors == false, + inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_features.cors == false, else => unreachable, }; } diff --git a/src/help.zon b/src/help.zon index bbb000d08..d00ecca73 100644 --- a/src/help.zon +++ b/src/help.zon @@ -409,9 +409,11 @@ \\ --cors-store-entry-limit \\ Maximum number of entries kept in the CorsStore. 0 means no limit. \\ Defaults to 1000. - \\ --disable-cors - \\ Skip CORS checks on fetch/XHR requests. - \\ Defaults to false. + \\ --disable-features + \\ Disable a feature that is on by default. Can be passed multiple times. + \\ Defaults to none disabled. + \\ Allowed values: + \\ cors Skip CORS checks on fetch/XHR requests. \\ --experimental-features \\ Enable an experimental, unstable feature. Can be passed multiple times. \\ Behavior may change or be removed without notice. From cebd8e3690bf40be7f1a33f29237e990443de888 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 28 Sep 2026 11:52:28 +0200 Subject: [PATCH 3/5] cors: fix unit tests when enabling cors by default --- src/browser/js/Env.zig | 4 ++++ src/testing.zig | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index edd99d786..c24748d6b 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -844,6 +844,10 @@ test "Env: Worker context " { const frame = try testing.createFrame(); defer testing.test_session.closeAllPages(); + // Navigate the frame first to avoid CORS blocking request for the worker. + try frame.navigate("http://localhost:9582/", .{}); + try testing.waitForFrame(); + const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", null, frame); var ls: js.Local.Scope = undefined; diff --git a/src/testing.zig b/src/testing.zig index 72619afd3..341ec2288 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -635,9 +635,37 @@ var serve_counts = [_]struct { name: []const u8, count: u32 = 0 }{ .{ .name = "prescan_module" }, }; +// dump a request in output for debugging. +fn dumpReq(req: *std.http.Server.Request) void { + std.debug.print("\n> {} {s}\n", .{ req.head.method, req.head.target }); + var it = req.iterateHeaders(); + while (it.next()) |h| { + std.debug.print("> {s}: {s}\n", .{ h.name, h.value }); + } +} + +fn origin(req: *std.http.Server.Request) ?[]const u8 { + var it = req.iterateHeaders(); + while (it.next()) |h| { + if (std.mem.eql(u8, "origin", h.name)) { + return h.value; + } + } + + return null; +} + fn testHTTPHandler(req: *std.http.Server.Request) !void { const path = req.head.target; + if (std.mem.eql(u8, path, "/")) { + return req.respond("", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + }, + }); + } + if (std.mem.eql(u8, path, "/xhr")) { return req.respond("1234567890" ** 10, .{ .extra_headers = &.{ @@ -1155,6 +1183,7 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { return req.respond(html, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + .{ .name = "Access-Control-Allow-Origin", .value = origin(req) orelse "*" }, }, }); } @@ -1201,6 +1230,16 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { } if (std.mem.eql(u8, path, "/echo_headers")) { + if (req.head.method == .OPTIONS) { + return req.respond("", .{ + .extra_headers = &.{ + .{ .name = "Access-Control-Allow-Origin", .value = origin(req) orelse "*" }, + .{ .name = "Access-Control-Allow-Methods", .value = "GET" }, + .{ .name = "Access-Control-Allow-Headers", .value = "x-hop" }, + }, + }); + } + // Echo every request header back as "name: value" lines, so tests // can assert on the headers a request actually sent. var buf: [8192]u8 = undefined; @@ -1213,6 +1252,7 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { return req.respond(buf[0..pos], .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/plain; charset=utf-8" }, + .{ .name = "Access-Control-Allow-Origin", .value = origin(req) orelse "*" }, }, }); } From 5789e1fc23bdb7c58fe32a40afa9919c2149d103 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 28 Sep 2026 14:03:39 +0200 Subject: [PATCH 4/5] README: remove `--experimental-features cors` --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index b7756a825..67309cdbe 100644 --- a/README.md +++ b/README.md @@ -421,8 +421,7 @@ zig build -Dwpt_extensions run -- serve \ --load-resources iframe \ --load-resources image \ --load-resources worker \ - --load-resources stylesheet \ - --experimental-features cors + --load-resources stylesheet ``` Then you can start the wptrunner from the demo's clone dir: From be65962d68a12a24098dee1f7708188ff33bbfe9 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Mon, 28 Sep 2026 14:04:11 +0200 Subject: [PATCH 5/5] testing: remove unused func --- src/testing.zig | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/testing.zig b/src/testing.zig index 341ec2288..604bb4935 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -635,15 +635,6 @@ var serve_counts = [_]struct { name: []const u8, count: u32 = 0 }{ .{ .name = "prescan_module" }, }; -// dump a request in output for debugging. -fn dumpReq(req: *std.http.Server.Request) void { - std.debug.print("\n> {} {s}\n", .{ req.head.method, req.head.target }); - var it = req.iterateHeaders(); - while (it.next()) |h| { - std.debug.print("> {s}: {s}\n", .{ h.name, h.value }); - } -} - fn origin(req: *std.http.Server.Request) ?[]const u8 { var it = req.iterateHeaders(); while (it.next()) |h| {