From eb5d46bb1136f09334624c37c2ccbf1c1ee0a330 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 30 May 2026 20:05:44 +0800 Subject: [PATCH 1/5] Fix potential segfault in CustomElement definition Fixes crash in WPT /custom-elements/CustomElementRegistry.html define has to get `observedAttributes` which itself could call define, invalidating any GetOrPutEntry pointers. Need to do it as two distinct lookup. --- src/browser/webapi/CustomElementRegistry.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index da15b369..c933da97 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -55,8 +55,12 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu break :blk tag; } else null; - const gop = try self._definitions.getOrPut(frame.arena, name); - if (gop.found_existing) { + + // Do not use getOrPut here to hold the GetOrPut entry until we need to write + // the definition. constructor.getPropertyValue() can fire a get accessor + // which could callback here and alter self._definitions, invalidating any + // GOP pointer we have. + if (self._definitions.contains(name)) { // Yes, this is the correct error to return when trying to redefine a name return error.NotSupported; } @@ -95,6 +99,10 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu } } + const gop = try self._definitions.getOrPut(frame.arena, owned_name); + if (gop.found_existing) { + return error.NotSupported; + } gop.key_ptr.* = owned_name; gop.value_ptr.* = definition; From 490b48ecd099cbe6ab543ac6794c5bb482787ca9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 30 May 2026 20:11:07 +0800 Subject: [PATCH 2/5] zig fmt --- src/browser/webapi/CustomElementRegistry.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index c933da97..8908fc23 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -55,7 +55,6 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu break :blk tag; } else null; - // Do not use getOrPut here to hold the GetOrPut entry until we need to write // the definition. constructor.getPropertyValue() can fire a get accessor // which could callback here and alter self._definitions, invalidating any From 2ecf9ced5d454720791660535648c2a55dd8123d Mon Sep 17 00:00:00 2001 From: Tom Clarke Date: Sun, 10 May 2026 10:44:19 -0400 Subject: [PATCH 3/5] Send cookies on WebSocket upgrade requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket upgrade handshake is an HTTP/1.1 request (RFC 6455 §4.1) and follows ordinary cookie semantics — RFC 6265 §5.4 attaches matching cookies to "any HTTP request" by domain/path. Without this, cookie- authenticated WebSocket endpoints (anything session-gated, e.g. Phoenix LiveView) reject the upgrade because their auth cookie never arrives. Read matching cookies from the session jar with the same opts shape HTTPDocument uses (`is_http: true, is_navigation: false`), and add a `Cookie:` request header on the upgrade if any apply. The TestWSServer captures the upgrade's Cookie header and exposes it to fixtures via a new `get-cookie` command. A `cookies_on_upgrade` fixture in websocket.html sets `document.cookie` then asserts the server received it on the upgrade. --- src/TestWSServer.zig | 28 ++++- src/browser/tests/net/websocket.html | 166 +++++++++++++++++++++++++++ src/browser/webapi/net/WebSocket.zig | 23 ++++ 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/src/TestWSServer.zig b/src/TestWSServer.zig index 6d28acf3..35e9e29e 100644 --- a/src/TestWSServer.zig +++ b/src/TestWSServer.zig @@ -95,6 +95,22 @@ fn handleClient(client: posix.socket_t) void { const key_end = std.mem.indexOfScalarPos(u8, request, key_line_start, '\r') orelse return; const key = request[key_line_start..key_end]; + // Capture the request's Cookie header value (if any) so the test + // fixture can ask for it back via the `get-cookie` command. Copy out + // of `request` because the WS frame loop below reuses `buf` for + // incoming frames, invalidating the slice. Buffer is sized to match + // `buf` since any cookie that fit in the request fits here. + var cookie_buf: [4096]u8 = undefined; + var cookie_len: usize = 0; + const cookie_header = "Cookie: "; + if (std.mem.indexOf(u8, request, cookie_header)) |cookie_start| { + const value_start = cookie_start + cookie_header.len; + const value_end = std.mem.indexOfScalarPos(u8, request, value_start, '\r') orelse value_start; + const value = request[value_start..value_end]; + cookie_len = @min(value.len, cookie_buf.len); + @memcpy(cookie_buf[0..cookie_len], value[0..cookie_len]); + } + // Compute accept key var hasher = std.crypto.hash.Sha1.init(.{}); hasher.update(key); @@ -128,7 +144,7 @@ fn handleClient(client: posix.socket_t) void { // Handle commands or echo if (frame.opcode == 1) { // Text - handleTextMessage(client, frame.payload) catch break; + handleTextMessage(client, frame.payload, cookie_buf[0..cookie_len]) catch break; } else if (frame.opcode == 2) { // Binary handleBinaryMessage(client, frame.payload) catch break; } @@ -233,12 +249,20 @@ const RecvBuffer = struct { } }; -fn handleTextMessage(client: posix.socket_t, payload: []const u8) !void { +fn handleTextMessage(client: posix.socket_t, payload: []const u8, cookie_header: []const u8) !void { // Command: force-close - close socket immediately without close frame if (std.mem.eql(u8, payload, "force-close")) { return error.ForceClose; } + // Command: get-cookie - send the Cookie header value the upgrade + // request carried (empty string if none). Used by the cookie-on- + // upgrade regression test. + if (std.mem.eql(u8, payload, "get-cookie")) { + try sendFrame(client, 1, "cookie:", cookie_header); + return; + } + // Command: send-large:N - send a message of N bytes if (std.mem.startsWith(u8, payload, "send-large:")) { const size_str = payload["send-large:".len..]; diff --git a/src/browser/tests/net/websocket.html b/src/browser/tests/net/websocket.html index 2c2e315b..e85813b2 100644 --- a/src/browser/tests/net/websocket.html +++ b/src/browser/tests/net/websocket.html @@ -579,3 +579,169 @@ }); } + + + + + + + + + + diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 4d2b589f..7c9c7f54 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -133,9 +133,32 @@ pub fn init(url: []const u8, protocols: [][]const u8, exec: *const Execution) !* var headers = try http_client.newHeaders(); errdefer headers.deinit(); + var has_extra_headers = false; + if (protocols.len > 0) { const header = try std.fmt.allocPrintSentinel(arena, "Sec-WebSocket-Protocol: {s}", .{try std.mem.join(arena, ", ", protocols)}, 0); try headers.add(header); + has_extra_headers = true; + } + + // Attach matching cookies from the session jar. Real browsers send + // cookies on the WebSocket upgrade request just like any other + // same-origin HTTP request — without this, server-side session + // checks (Phoenix LiveView, anything cookie-authenticated) reject + // the upgrade. + var cookie_buf: std.Io.Writer.Allocating = .init(arena); + try exec.session.cookie_jar.forRequest(resolved_url, &cookie_buf.writer, .{ + .is_http = true, + .is_navigation = false, + .origin_url = exec.url.*, + }); + if (cookie_buf.written().len > 0) { + const cookie_header = try std.fmt.allocPrintSentinel(arena, "Cookie: {s}", .{cookie_buf.written()}, 0); + try headers.add(cookie_header); + has_extra_headers = true; + } + + if (has_extra_headers) { try conn.setHeaders(&headers); } From bf1b5e55060f7dc025bde71e09cf6770cdf21862 Mon Sep 17 00:00:00 2001 From: Joe Schafer Date: Sun, 31 May 2026 00:34:45 -0700 Subject: [PATCH 4/5] browser/URL: ignore query and fragment slashes in URL resolve Resolve relative request URLs against only the path component of the base URL. Previously, URL.resolve searched for the last slash in the whole base URL after the authority, so slashes inside a query string or hash route could be mistaken for path directory separators. The reported failure was a hash-routed page loaded at http://127.0.0.1:8123/#/login. When that page ran fetch("api/users/login", { method: "POST" }), browser-compatible URL resolution should have requested http://127.0.0.1:8123/api/users/login. Instead, Lightpanda 0.3.1 reported the response URL as http://127.0.0.1:8123/#/api/users/login, and the HTTP server received POST /. That meant the server returned the single-page app shell instead of the JSON login response. The failure broke a RealWorld/Conduit-style SPA benchmark after login because the app used relative API URLs such as api/users/login. Changing the benchmark fixture to root-absolute API URLs, such as /api/users/login, worked around the benchmark failure, but left Lightpanda incompatible with normal browser behavior for relative request URLs on hash-routed pages. The same issue was not limited to fragments. A base URL such as https://example/app/page?next=/foo/bar also contains slashes after the path component. Those slashes must not affect how path-relative inputs such as api/users/login or ../api/users/login are merged with the base path. This change bounds the directory calculation at the first ? or # in the base URL and searches for the last path slash only inside that path component. Root-absolute inputs still keep only the scheme and authority, query-only inputs still replace the query on the current path, and fragment-only inputs still replace the fragment on the current path. This matches the behavior of the WHATWG URL algorithm, Chromium's relative URL canonicalization, Node's WHATWG URL implementation, Go's net/url ResolveReference, and Python's urllib.parse.urljoin for the cases covered here. All of those implementations split the base URL into components before merging a path-relative reference, so slashes in the query or fragment do not change the base directory. The URL resolver tests now cover the original hash-route repro, slashes inside query strings and fragments, host-only bases with query or fragment components, dot-segment paths, query-only references, and fragment-only references. The fetch Web API tests also move a page to /#/login and POST fetch("xhr"), expecting the request URL to resolve to http://127.0.0.1:9582/xhr rather than the hash route. Verified with: mise x zig@0.15.2 -- zig fmt --check ./*.zig ./**/*.zig mise x zig@0.15.2 rust@stable -- make ZIG=zig test --- src/browser/URL.zig | 67 ++++++++++++++++++++- src/browser/tests/net/fetch_hash_route.html | 28 +++++++++ src/browser/webapi/net/Fetch.zig | 1 + 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/browser/tests/net/fetch_hash_route.html diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 620e63a2..891ae860 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -121,7 +121,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o const scheme_end = std.mem.indexOf(u8, base, "://"); const authority_start = if (scheme_end) |end| end + 3 else 0; - const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len; + const path_start = std.mem.indexOfAnyPos(u8, base, authority_start, "/?#") orelse base.len; + const path_end = std.mem.indexOfAnyPos(u8, base, path_start, "?#") orelse base.len; if (path[0] == '/') { const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); @@ -129,8 +130,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o } var normalized_base: []const u8 = base[0..path_start]; - if (path_start < base.len) { - if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 ..], '/')) |pos| { + if (path_start < path_end) { + if (std.mem.lastIndexOfScalar(u8, base[path_start + 1 .. path_end], '/')) |pos| { normalized_base = base[0 .. path_start + 1 + pos]; } } @@ -973,6 +974,66 @@ test "URL: resolve" { .path = "something.js", .expected = "https://example/xyz/abc/something.js", }, + .{ + .base = "http://127.0.0.1:8123/#/login", + .path = "api/users/login", + .expected = "http://127.0.0.1:8123/api/users/login", + }, + .{ + .base = "https://example/app/page?next=/foo/bar", + .path = "api/users/login", + .expected = "https://example/app/api/users/login", + }, + .{ + .base = "https://example/app/page#/foo/bar", + .path = "api/users/login", + .expected = "https://example/app/api/users/login", + }, + .{ + .base = "https://example?next=/foo/bar", + .path = "api/users/login", + .expected = "https://example/api/users/login", + }, + .{ + .base = "https://example#/foo/bar", + .path = "api/users/login", + .expected = "https://example/api/users/login", + }, + .{ + .base = "https://example?next=/foo/bar", + .path = "/api/users/login", + .expected = "https://example/api/users/login", + }, + .{ + .base = "https://example/app/page?next=/foo/bar", + .path = "../api/users/login", + .expected = "https://example/api/users/login", + }, + .{ + .base = "https://example/app/page#/foo/bar", + .path = "../api/users/login", + .expected = "https://example/api/users/login", + }, + .{ + .base = "https://example/app/dir/?next=/foo/bar", + .path = "../api/users/login", + .expected = "https://example/app/api/users/login", + }, + .{ + .base = "https://example/app/dir/#/foo/bar", + .path = "../api/users/login", + .expected = "https://example/app/api/users/login", + }, + .{ + .base = "https://example/app/page?next=/foo/bar", + .path = "?q=/api/users/login", + .expected = "https://example/app/page?q=/api/users/login", + }, + .{ + .base = "https://example/app/page#/foo/bar", + .path = "#/api/users/login", + .expected = "https://example/app/page#/api/users/login", + }, .{ .base = "https://example/xyz/abc/123", .path = "/something.js", diff --git a/src/browser/tests/net/fetch_hash_route.html b/src/browser/tests/net/fetch_hash_route.html new file mode 100644 index 00000000..8a794550 --- /dev/null +++ b/src/browser/tests/net/fetch_hash_route.html @@ -0,0 +1,28 @@ + + + + diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 1f69ef0e..74b3351c 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -270,4 +270,5 @@ fn httpShutdownCallback(ctx: *anyopaque) void { const testing = @import("../../../testing.zig"); test "WebApi: fetch" { try testing.htmlRunner("net/fetch.html", .{}); + try testing.htmlRunner("net/fetch_hash_route.html", .{}); } From 14f1ef35f14a36bfee84dff66df12ec269097335 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 1 Jun 2026 08:35:19 +0800 Subject: [PATCH 5/5] URL.isHTTPs -> URL.isSecure and consider wss:// Use conn.setCookie in websocket --- src/browser/HttpClient.zig | 8 ++++--- src/browser/URL.zig | 4 ++-- src/browser/webapi/net/WebSocket.zig | 27 ++++++++++------------ src/browser/webapi/storage/Cookie.zig | 2 +- src/browser/webapi/storage/CookieStore.zig | 6 ++--- src/cdp/domains/network.zig | 2 +- src/cdp/domains/storage.zig | 2 +- 7 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 81c4dc14..6eb3611e 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1247,10 +1247,12 @@ pub const Request = struct { .origin_url = self.cookie_origin, .is_navigation = self.resource_type == .document, }); - const written = aw.written(); - if (written.len == 0) return null; + if (aw.written().len == 0) { + return null; + } try aw.writer.writeByte(0); - return written.ptr[0..written.len :0]; + const written = aw.written(); + return written.ptr[0 .. written.len - 1 :0]; } pub fn deinit(self: *const Request) void { diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 620e63a2..f2650164 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -464,8 +464,8 @@ pub fn getProtocol(raw: [:0]const u8) []const u8 { return raw[0 .. pos + 1]; } -pub fn isHTTPS(raw: [:0]const u8) bool { - return std.mem.startsWith(u8, raw, "https:"); +pub fn isSecure(raw: [:0]const u8) bool { + return std.mem.startsWith(u8, raw, "https:") or std.mem.startsWith(u8, raw, "wss:"); } pub fn getHostname(raw: [:0]const u8) []const u8 { diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 7c9c7f54..c80b520f 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -141,21 +141,18 @@ pub fn init(url: []const u8, protocols: [][]const u8, exec: *const Execution) !* has_extra_headers = true; } - // Attach matching cookies from the session jar. Real browsers send - // cookies on the WebSocket upgrade request just like any other - // same-origin HTTP request — without this, server-side session - // checks (Phoenix LiveView, anything cookie-authenticated) reject - // the upgrade. - var cookie_buf: std.Io.Writer.Allocating = .init(arena); - try exec.session.cookie_jar.forRequest(resolved_url, &cookie_buf.writer, .{ - .is_http = true, - .is_navigation = false, - .origin_url = exec.url.*, - }); - if (cookie_buf.written().len > 0) { - const cookie_header = try std.fmt.allocPrintSentinel(arena, "Cookie: {s}", .{cookie_buf.written()}, 0); - try headers.add(cookie_header); - has_extra_headers = true; + { + var buf: std.Io.Writer.Allocating = .init(arena); + try exec.session.cookie_jar.forRequest(resolved_url, &buf.writer, .{ + .is_http = true, + .is_navigation = false, + .origin_url = exec.url.*, + }); + if (buf.written().len > 0) { + try buf.writer.writeByte(0); + const written = buf.written(); + try conn.setCookies(written.ptr[0 .. written.len - 1 :0]); + } } if (has_extra_headers) { diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 0e023705..b7c721ea 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -577,7 +577,7 @@ pub const Jar = struct { const target = PreparedUri{ .host = URL.getHostname(target_url), .path = URL.getPathname(target_url), - .secure = URL.isHTTPS(target_url), + .secure = URL.isSecure(target_url), }; const same_site = try areSameSite(opts.origin_url, target.host); diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 4d679e54..cf365537 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -76,7 +76,7 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo const target = Cookie.PreparedUri{ .host = URL.getHostname(doc_url), .path = URL.getPathname(doc_url), - .secure = URL.isHTTPS(doc_url), + .secure = URL.isSecure(doc_url), }; if (target.host.len == 0) return; @@ -353,7 +353,7 @@ fn matchCookies( const target = Cookie.PreparedUri{ .host = URL.getHostname(url_resolved), .path = URL.getPathname(url_resolved), - .secure = URL.isHTTPS(url_resolved), + .secure = URL.isSecure(url_resolved), }; if (target.host.len == 0) return error.SecurityError; @@ -408,7 +408,7 @@ fn storeCookie(exec: *const Execution, init: CookieInit) !void { if (std.mem.indexOfAny(u8, d, ";\r\n\x00") != null) return error.InvalidCookieDomain; } - const is_https = URL.isHTTPS(url); + const is_https = URL.isSecure(url); // Per spec, SameSite=None requires Secure. CookieStore additionally // marks any cookie written from an HTTPS document as Secure. const secure = is_https or init.sameSite == .none; diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 5a33df88..864c72f5 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -233,7 +233,7 @@ fn getCookies(cmd: *CDP.Command) !void { urls.appendAssumeCapacity(.{ .host = try Cookie.parseDomain(cmd.arena, url, null), .path = try Cookie.parsePath(cmd.arena, url, null), - .secure = URL.isHTTPS(url), + .secure = URL.isSecure(url), }); } diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index dac20e10..7e38c132 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -153,7 +153,7 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { const domain = try Cookie.parseDomain(a, param.url, param.domain); const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path); - const secure = if (param.secure) |s| s else if (param.url) |url| URL.isHTTPS(url) else false; + const secure = if (param.secure) |s| s else if (param.url) |url| URL.isSecure(url) else false; break :blk Cookie{ .arena = arena,