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/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 b8041ecb..02540a48 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]; } } @@ -482,8 +483,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 { @@ -991,6 +992,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/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/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index a1dbbf5e..8c0e0fd3 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -55,8 +55,11 @@ 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 +98,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; 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", .{}); } diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 4d2b589f..c80b520f 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -133,9 +133,29 @@ 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; + } + + { + 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) { try conn.setHeaders(&headers); } diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 259dd6e9..6d73a22e 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -574,7 +574,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,