From a71bf101d6938e68c0bf7714f147f4e44bed3d8d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Aug 2026 17:09:05 +0800 Subject: [PATCH] http: start implementation correct referrer policy Referrer header is now set based on the computation of the frame url, the target url, and the referrer policy which is parsed from the response header and/or a --- src/browser/Frame.zig | 40 ++-- src/browser/referrer.zig | 222 +++++++++++++++++++++++ src/browser/webapi/Document.zig | 16 +- src/browser/webapi/element/html/Meta.zig | 18 ++ 4 files changed, 274 insertions(+), 22 deletions(-) create mode 100644 src/browser/referrer.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ea0651489..d9f2042b9 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -34,6 +34,7 @@ const h5e = @import("parser/html5ever.zig"); const CustomElementReactions = @import("CustomElementReactions.zig"); const URL = @import("URL.zig"); +const referrer = @import("referrer.zig"); const Blob = @import("webapi/Blob.zig"); const FileList = @import("webapi/FileList.zig"); const Node = @import("webapi/Node.zig"); @@ -331,6 +332,9 @@ _navigated_options: ?NavigatedOpts = null, _http_status: ?u16 = null, _http_headers: std.ArrayList(HttpHeader) = .empty, +_referrer: ?[]const u8 = null, +referrer_policy: referrer.Policy = .default, + pub const HttpHeader = struct { name: []const u8, value: []const u8, @@ -593,8 +597,9 @@ pub fn httpMetadata(self: *const Frame) HttpMetadata { // Add common headers for a request: // * referer pub fn headersForRequest(self: *Frame, transfer: *HttpClient.Transfer) !void { - if (std.mem.startsWith(u8, self.url, "http")) { - try transfer.addHeader("Referer", self.url, .{}); + const arena = transfer.arena.allocator(); + if (try referrer.compute(arena, self.referrer_policy, self.url, transfer.req.url)) |ref| { + try transfer.addHeader("Referer", ref, .{}); } } @@ -742,6 +747,9 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo self._http_status = null; self._http_headers = .empty; + self._referrer = null; + self.referrer_policy = .default; + self.url = blk: { if (URL.isCompleteHTTPUrl(request_url)) { break :blk try self.arena.dupeZ(u8, request_url); @@ -793,6 +801,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo } if (opts.referer) |ref| { try transfer.addHeader("Referer", ref, .{}); + self._referrer = try self.arena.dupe(u8, ref); } } @@ -951,20 +960,15 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url // Capture the originating frame's URL as the Referer for this // navigation. The originator's frame may be torn down before navigate() - // runs (processRootQueuedNavigation rebuilds the Page in-place), so dup - // into the QueuedNavigation arena which outlives that tear-down. + // runs (processRootQueuedNavigation rebuilds the Page in-place), so + // allocate from the QueuedNavigation arena which outlives that tear-down. var nav_opts = opts; if (std.mem.startsWith(u8, originator.url, "http")) { - // The same dup feeds two purposes: Referer header (subject to - // Referrer-Policy in the future) and SameSite computation (which - // must use the real initiator regardless of policy). We share the - // same allocation for both. - const dup = try arena.dupeZ(u8, originator.url); if (nav_opts.referer == null) { - nav_opts.referer = dup; + nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, originator.url, resolved_url); } if (nav_opts.initiator_url == null) { - nav_opts.initiator_url = dup; + nav_opts.initiator_url = try arena.dupeZ(u8, originator.url); } } if (nav_opts.initiator_origin == null) { @@ -1274,6 +1278,11 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer. .name = try self.arena.dupe(u8, hdr.name), .value = try self.arena.dupe(u8, hdr.value), }); + if (std.ascii.eqlIgnoreCase(hdr.name, "referrer-policy")) { + if (referrer.parseHeader(hdr.value)) |rp| { + self.referrer_policy = rp; + } + } } if (self._navigated_options) |no| { @@ -1843,13 +1852,14 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { const was_sorted = self.child_frames_sorted; self.child_frames_sorted = false; - // Iframe's initial src request carries the parent's URL as Referer and - // as the SameSite initiator. Parent frame outlives this navigate() - // call, so the slice is safe. + // Iframe's initial src request carries the parent's URL as Referer + // (subject to the parent's Referrer-Policy) and as the SameSite + // initiator. Parent frame outlives this navigate() call, so the slice + // is safe; navigate dupes what it keeps. const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, self.url, "http")) self.url else null; new_frame.navigate(url, .{ .reason = .initialFrameNavigation, - .referer = parent_url, + .referer = try referrer.compute(self.call_arena, self.referrer_policy, self.url, url), .initiator_url = parent_url, .initiator_origin = self.origin, }) catch |err| { diff --git a/src/browser/referrer.zig b/src/browser/referrer.zig new file mode 100644 index 000000000..0e3250441 --- /dev/null +++ b/src/browser/referrer.zig @@ -0,0 +1,222 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +// Referrer Policy: https://www.w3.org/TR/referrer-policy/ +const std = @import("std"); +const URL = @import("URL.zig"); + +const Allocator = std.mem.Allocator; + +pub const Policy = enum { + no_referrer, + no_referrer_when_downgrade, + origin, + origin_when_cross_origin, + same_origin, + strict_origin, + strict_origin_when_cross_origin, + unsafe_url, + + pub const default: Policy = .strict_origin_when_cross_origin; +}; + +pub fn parse(value: []const u8) ?Policy { + const map = std.StaticStringMapWithEql(Policy, staticStringMapEqlAsciiIgnoreCase).initComptime(.{ + .{ "no-referrer", Policy.no_referrer }, + .{ "no-referrer-when-downgrade", Policy.no_referrer_when_downgrade }, + .{ "origin", Policy.origin }, + .{ "origin-when-cross-origin", Policy.origin_when_cross_origin }, + .{ "same-origin", Policy.same_origin }, + .{ "strict-origin", Policy.strict_origin }, + .{ "strict-origin-when-cross-origin", Policy.strict_origin_when_cross_origin }, + .{ "unsafe-url", Policy.unsafe_url }, + }); + return map.get(value); +} + +pub fn parseHeader(value: []const u8) ?Policy { + var policy: ?Policy = null; + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |token| { + if (parse(std.mem.trim(u8, token, " \t"))) |p| { + policy = p; + } + } + return policy; +} + +pub fn parseMeta(value: []const u8) ?Policy { + if (parse(value)) |p| { + return p; + } + // legacy values + const map = std.StaticStringMapWithEql(Policy, staticStringMapEqlAsciiIgnoreCase).initComptime(.{ + .{ "never", Policy.no_referrer }, + .{ "always", Policy.unsafe_url }, + .{ "origin-when-crossorigin", Policy.origin_when_cross_origin }, + .{ "default", Policy.default }, + }); + return map.get(value); +} + +// returns the value to send as the Referrer header based on the target_url +// and the given policy +pub fn compute(arena: Allocator, policy: Policy, referrer_url: [:0]const u8, target_url: [:0]const u8) !?[]const u8 { + if (policy == .no_referrer) { + return null; + } + + const referrer_origin = (try URL.getOrigin(arena, referrer_url)) orelse { + // blob:, data: ... don't set get a referrer + return null; + }; + + const same_origin = blk: { + const target_origin = (try URL.getOrigin(arena, target_url)) orelse break :blk false; + break :blk std.ascii.eqlIgnoreCase(referrer_origin, target_origin); + }; + const downgrade = URL.isSecure(referrer_url) and !URL.isSecure(target_url); + + const full = switch (policy) { + .no_referrer => unreachable, + .unsafe_url => true, + .origin => false, + .no_referrer_when_downgrade => if (downgrade) return null else true, + .same_origin => if (same_origin) true else return null, + .origin_when_cross_origin => same_origin, + .strict_origin => if (downgrade) return null else false, + .strict_origin_when_cross_origin => if (same_origin) true else if (downgrade) return null else false, + }; + + if (full) { + // Serializing through origin + path + query strips credentials and + // the fragment, and normalizes away default ports. + const value = try std.fmt.allocPrint(arena, "{s}{s}{s}", .{ + referrer_origin, + URL.getPathname(referrer_url), + URL.getSearch(referrer_url), + }); + + if (value.len <= 4096) { + // spec limit, if it's more than this, we falllback to the origin + return value; + } + } + return try std.fmt.allocPrint(arena, "{s}/", .{referrer_origin}); +} + +fn staticStringMapEqlAsciiIgnoreCase(a: []const u8, b: []const u8) bool { + for (a, b) |a_c, b_c| { + if (std.ascii.toLower(a_c) != std.ascii.toLower(b_c)) { + return false; + } + } + return true; +} + +const testing = @import("../testing.zig"); +test "referrer: parse" { + try testing.expectEqual(Policy.no_referrer, parse("no-referrer")); + try testing.expectEqual(Policy.unsafe_url, parse("Unsafe-URL")); + try testing.expectEqual(null, parse("")); + try testing.expectEqual(null, parse("never")); + try testing.expectEqual(null, parse("no-referrer ")); + + try testing.expectEqual(null, parseHeader("")); + try testing.expectEqual(null, parseHeader("nope")); + try testing.expectEqual(Policy.origin, parseHeader("origin")); + try testing.expectEqual(Policy.same_origin, parseHeader("origin, same-origin")); + try testing.expectEqual(Policy.origin, parseHeader("origin, garbage")); + try testing.expectEqual(Policy.same_origin, parseHeader(" origin ,\tsame-origin ")); + + try testing.expectEqual(Policy.no_referrer, parseMeta("never")); + try testing.expectEqual(Policy.unsafe_url, parseMeta("always")); + try testing.expectEqual(Policy.origin_when_cross_origin, parseMeta("origin-when-crossorigin")); + try testing.expectEqual(Policy.strict_origin_when_cross_origin, parseMeta("default")); + try testing.expectEqual(Policy.origin, parseMeta("origin")); + try testing.expectEqual(null, parseMeta("garbage")); +} + +test "referrer: compute" { + const Case = struct { + policy: Policy, + referrer: [:0]const u8, + target: [:0]const u8, + expected: ?[]const u8, + }; + + const cases = [_]Case{ + .{ .policy = .no_referrer, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = null }, + + .{ .policy = .unsafe_url, .referrer = "http://a.com/p?q=1#frag", .target = "https://b.com/", .expected = "http://a.com/p?q=1" }, + .{ .policy = .unsafe_url, .referrer = "https://a.com/p", .target = "http://b.com/", .expected = "https://a.com/p" }, + .{ .policy = .unsafe_url, .referrer = "https://user:pass@a.com/p", .target = "http://b.com/", .expected = "https://a.com/p" }, + .{ .policy = .unsafe_url, .referrer = "https://a.com:443/p", .target = "http://b.com/", .expected = "https://a.com/p" }, + .{ .policy = .unsafe_url, .referrer = "http://a.com", .target = "http://b.com/", .expected = "http://a.com/" }, + + .{ .policy = .origin, .referrer = "http://a.com:8000/p?q=1", .target = "http://a.com:8000/x", .expected = "http://a.com:8000/" }, + + .{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = "http://a.com/p" }, + .{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = null }, + .{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "https://a.com/x", .expected = null }, + + .{ .policy = .origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = "http://a.com/p" }, + .{ .policy = .origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" }, + + .{ .policy = .strict_origin, .referrer = "https://a.com/p", .target = "http://a.com/x", .expected = null }, + .{ .policy = .strict_origin, .referrer = "https://a.com/p", .target = "https://b.com/x", .expected = "https://a.com/" }, + .{ .policy = .strict_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" }, + + .{ .policy = .no_referrer_when_downgrade, .referrer = "https://a.com/p", .target = "http://b.com/x", .expected = null }, + .{ .policy = .no_referrer_when_downgrade, .referrer = "https://a.com/p", .target = "https://b.com/x", .expected = "https://a.com/p" }, + .{ .policy = .no_referrer_when_downgrade, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/p" }, + + .{ .policy = .strict_origin_when_cross_origin, .referrer = "http://a.com/p?q=1", .target = "http://a.com/x", .expected = "http://a.com/p?q=1" }, + .{ .policy = .strict_origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" }, + .{ .policy = .strict_origin_when_cross_origin, .referrer = "https://a.com/p", .target = "http://b.com/x", .expected = null }, + .{ .policy = .strict_origin_when_cross_origin, .referrer = "https://a.com/p", .target = "http://a.com/x", .expected = null }, + + // no referrer from non-http(s) documents + .{ .policy = .unsafe_url, .referrer = "about:blank", .target = "http://b.com/x", .expected = null }, + .{ .policy = .unsafe_url, .referrer = "data:text/html,x", .target = "http://b.com/x", .expected = null }, + }; + + for (cases) |case| { + const actual = try compute(testing.arena_allocator, case.policy, case.referrer, case.target); + if (case.expected) |expected| { + try testing.expectEqual(expected, actual orelse return error.UnexpectedNull); + } else { + try testing.expectEqual(null, actual); + } + } +} + +test "referrer: compute caps at 4096 bytes" { + const path = "/" ++ ("a" ** 4096); + const url = "http://a.com" ++ path; + // over the cap: falls back to the origin form + try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .unsafe_url, url, "http://b.com/x")).?); + try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .no_referrer_when_downgrade, url, "http://a.com/x")).?); + + // exactly at the cap: sent in full + const at_cap = "http://a.com/" ++ ("a" ** (4096 - "http://a.com/".len)); + try testing.expectEqual(at_cap, (try compute(testing.arena_allocator, .unsafe_url, at_cap, "http://b.com/x")).?); + + // origin-only policies are unaffected by the referrer's length + try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .origin, url, "http://b.com/x")).?); +} diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 78e2a3f83..e5e0b5e6e 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -212,6 +212,11 @@ pub fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 { }); } +pub fn getReferrer(self: *const Document) []const u8 { + const frame = self._frame orelse return ""; + return frame._referrer orelse ""; +} + pub fn getCharset(self: *const Document) []const u8 { if (self._charset) |charset| { return charset; @@ -1588,15 +1593,12 @@ pub const JsApi = struct { pub const hasFocus = bridge.function(Document.hasFocus, .{}); pub const prerendering = bridge.property(false, .{ .template = false }); - pub const characterSet = bridge.accessor(getCharacterSet, null, .{}); - pub const charset = bridge.accessor(getCharacterSet, null, .{}); - pub const inputEncoding = bridge.accessor(getCharacterSet, null, .{}); + pub const characterSet = bridge.accessor(Document.getCharset, null, .{}); + pub const charset = bridge.accessor(Document.getCharset, null, .{}); + pub const inputEncoding = bridge.accessor(Document.getCharset, null, .{}); pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{}); pub const lastModified = bridge.accessor(Document.getLastModified, null, .{}); - fn getCharacterSet(self: *const Document) []const u8 { - return self.getCharset(); - } - pub const referrer = bridge.property("", .{ .template = false }); + pub const referrer = bridge.accessor(Document.getReferrer, null, .{}); // Generates a getter/setter pair backed by the frame's attribute-listener // map, like onclick above, for other document event handler properties. diff --git a/src/browser/webapi/element/html/Meta.zig b/src/browser/webapi/element/html/Meta.zig index ea5baee1a..7c869c290 100644 --- a/src/browser/webapi/element/html/Meta.zig +++ b/src/browser/webapi/element/html/Meta.zig @@ -16,11 +16,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); + const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const referrer = @import("../../../referrer.zig"); const Meta = @This(); @@ -78,6 +81,21 @@ pub fn setScheme(self: *Meta, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("scheme"), .wrap(value), frame); } +pub const Build = struct { + // sets the document's referrer policy. + pub fn created(node: *Node, frame: *Frame) !void { + const el = node.as(Element); + const name = el.getAttributeSafe(comptime .wrap("name")) orelse return; + if (std.ascii.eqlIgnoreCase(name, "referrer") == false) { + return; + } + const content = el.getAttributeSafe(comptime .wrap("content")) orelse return; + if (referrer.parseMeta(content)) |rp| { + frame.referrer_policy = rp; + } + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(MetaElement);