diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index f96eb0640..a644937e0 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -518,3 +518,18 @@ }); } + + diff --git a/src/browser/tests/net/headers.html b/src/browser/tests/net/headers.html index 37279c349..e51f061e9 100644 --- a/src/browser/tests/net/headers.html +++ b/src/browser/tests/net/headers.html @@ -380,3 +380,47 @@ testing.expectEqual('b=2; HttpOnly', cookies[1]); } + + + + diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index 929aff89f..9051f7a39 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -754,3 +754,19 @@ handler runs, abort() cancels the re-send and no load is delivered. testing.expectEqual('9000!!!', first.over); } + + diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index d628e96e5..c94216599 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -12,6 +12,14 @@ const Execution = js.Execution; const Headers = @This(); _list: KeyValueList, +_guard: Guard = .none, + +// What mutation JS can make +pub const Guard = enum { + none, // don't block anything + response, // block forbidden response headers + immutable, // block everythig +}; pub const InitOpts = union(enum) { obj: *Headers, @@ -20,7 +28,11 @@ pub const InitOpts = union(enum) { }; pub fn init(opts_: ?InitOpts, exec: *const Execution) !*Headers { - const list = blk: { + return initGuarded(opts_, .none, exec); +} + +pub fn initGuarded(opts_: ?InitOpts, guard: Guard, exec: *const Execution) !*Headers { + var list = blk: { const opts = opts_ orelse break :blk KeyValueList.init(); switch (opts) { .obj => |obj| break :blk try KeyValueList.copy(exec.arena, obj._list), @@ -37,18 +49,56 @@ pub fn init(opts_: ?InitOpts, exec: *const Execution) !*Headers { } }; + if (guard == .response) { + // easier to use the KVL's creation upfront and then strip these out + list.delete("set-cookie", null); + list.delete("set-cookie2", null); + } + return exec._factory.create(Headers{ ._list = list, + ._guard = guard, }); } +pub fn isForbiddenResponseHeaderName(name: []const u8) bool { + if (std.ascii.eqlIgnoreCase(name, "set-cookie")) { + return true; + } + + if (std.ascii.eqlIgnoreCase(name, "set-cookie2")) { + // yup, this is a real, never used / deprecated, header + return true; + } + + return false; +} + +const Mutation = enum { proceed, ignore }; + +fn checkGuard(self: *const Headers, name: []const u8) !Mutation { + return switch (self._guard) { + .none => .proceed, + .immutable => error.TypeError, + .response => if (isForbiddenResponseHeaderName(name)) .ignore else .proceed, + }; +} + pub fn append(self: *Headers, name: []const u8, value: []const u8, exec: *const Execution) !void { const normalized_name = normalizeHeaderName(name, exec.buf); + const mutation = try self.checkGuard(normalized_name); + if (mutation == .ignore) { + return; + } try self._list.append(exec.arena, normalized_name, value); } -pub fn delete(self: *Headers, name: []const u8, exec: *const Execution) void { +pub fn delete(self: *Headers, name: []const u8, exec: *const Execution) !void { const normalized_name = normalizeHeaderName(name, exec.buf); + const mutation = try self.checkGuard(normalized_name); + if (mutation == .ignore) { + return; + } self._list.delete(normalized_name, null); } @@ -76,6 +126,10 @@ pub fn has(self: *const Headers, name: []const u8, exec: *const Execution) bool pub fn set(self: *Headers, name: []const u8, value: []const u8, exec: *const Execution) !void { const normalized_name = normalizeHeaderName(name, exec.buf); + const mutation = try self.checkGuard(normalized_name); + if (mutation == .ignore) { + return; + } try self._list.set(exec.arena, normalized_name, value); } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 79ff5f936..daf658eef 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -115,7 +115,7 @@ fn initWithArena(arena: *lp.Arena, body_: ?BodyInit, opts_: ?InitOpts, exec: *co } }; - const headers = try Headers.init(opts.headers, exec); + const headers = try Headers.initGuarded(opts.headers, .response, exec); if (content_type) |ct| { if (!headers.has("content-type", exec)) { try headers.append("content-type", ct, exec); @@ -151,7 +151,7 @@ pub fn createError(exec: *const Execution) !*Response { ._body = .empty, ._type = .@"error", ._is_redirected = false, - ._headers = try Headers.init(null, exec), + ._headers = try .initGuarded(null, .immutable, exec), }; arena.report(); return self; @@ -171,7 +171,9 @@ pub fn createRedirect(url_: []const u8, status_: ?u16, exec: *const Execution) ! const location = try URL.resolve(arena.allocator(), exec.base(), url_, .{ .encoding = exec.charset.* }); const headers = try Headers.init(null, exec); + // append location directly, then lock the headers try headers.set("location", location, exec); + headers._guard = .immutable; const self = try arena.create(Response); self.* = .{ @@ -204,7 +206,7 @@ pub fn createJson(data: js.Value, opts_: ?InitOpts, exec: *const Execution) !*Re const opts = opts_ orelse InitOpts{}; const status_text = if (opts.statusText) |st| try arena.dupe(u8, st) else ""; - const headers = try Headers.init(opts.headers, exec); + const headers = try Headers.initGuarded(opts.headers, .response, exec); if (!headers.has("content-type", exec)) { try headers.append("content-type", "application/json", exec); } @@ -553,7 +555,7 @@ pub fn clone(self: *const Response, exec: *const Execution) !*Response { ._body = body, ._type = self._type, ._is_redirected = self._is_redirected, - ._headers = try Headers.init(.{ .obj = self._headers }, exec), + ._headers = try .initGuarded(.{ .obj = self._headers }, self._headers._guard, exec), ._http_transfer = null, }; arena.report(); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index d503ac2bb..dc88a5e57 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -634,6 +634,9 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { var it = transfer.responseHeaderIterator(); while (it.next()) |hdr| { + if (Headers.isForbiddenResponseHeaderName(hdr.name)) { + continue; + } const joined = try std.fmt.allocPrint(self._arena.allocator(), "{s}: {s}", .{ hdr.name, hdr.value }); try self._response_headers.append(self._arena.allocator(), joined); } diff --git a/src/testing.zig b/src/testing.zig index c1706cb36..71976ac37 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -1140,6 +1140,15 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/set_cookie")) { + return req.respond("", .{ + .extra_headers = &.{ + .{ .name = "Set-Cookie", .value = "lp_hidden=1; Path=/set_cookie_scope" }, + .{ .name = "X-Visible", .value = "yes" }, + }, + }); + } + if (std.mem.eql(u8, path, "/redirect_same_echo_headers")) { // Same-origin 302 to /echo_headers: Authorization must survive the hop. return req.respond("", .{