From 00c42dec4edc048997dfe37b3e9d8e905b0bdee0 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 27 Apr 2026 08:04:47 +0200 Subject: [PATCH] http: inherit request URL fragment across fragment-less redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 7231 §7.1.2, when a 3xx response carries a Location header without a fragment, the user agent must process the redirect as if the value inherited the fragment of the request URL. URL.resolve follows RFC 3986 §5.3 which drops the base fragment, so handleRedirect now reattaches the original fragment when the resolved target has none. Closes #2263 --- src/browser/HttpClient.zig | 14 +++++++++- src/cdp/domains/page.zig | 54 ++++++++++++++++++++++++++++++++++++++ src/testing.zig | 26 ++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index fc217e3d..0832db1a 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1572,7 +1572,19 @@ pub const Transfer = struct { } const base_url = try conn.getEffectiveUrl(); - break :blk try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + const resolved = try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + + // RFC 7231 §7.1.2: if the Location value has no fragment, the redirect + // inherits the fragment from the URI used to generate the request. + // URL.resolve follows RFC 3986 §5.3, which drops the base fragment when + // the relative ref has none, so we re-attach it here. + if (URL.getHash(resolved).len == 0) { + const original_hash = URL.getHash(transfer.url); + if (original_hash.len != 0) { + break :blk try std.mem.joinZ(arena, "", &.{ resolved, original_hash }); + } + } + break :blk resolved; }; try transfer.updateURL(url); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index b0aae79e..2bfb8988 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1003,6 +1003,60 @@ test "cdp.frame: reload" { } } +test "cdp.frame: navigate inherits original fragment across redirect" { + // RFC 7231 §7.1.2: when a 3xx Location header has no fragment, the redirect + // inherits the fragment of the request URL. + var ctx = try testing.context(); + defer ctx.deinit(); + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + + { + // Location: /redirect-target (no fragment) — must inherit #myfrag. + try ctx.processMessage(.{ + .id = 40, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-no-fragment#myfrag" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target#myfrag", frame.url); + } + + { + // Location: /redirect-target#target_fragment — target's fragment wins. + try ctx.processMessage(.{ + .id = 41, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-with-fragment#requested" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target#target_fragment", frame.url); + } + + { + // No fragment on either side — final URL has no fragment. + try ctx.processMessage(.{ + .id = 42, + .method = "Page.navigate", + .params = .{ .url = "http://127.0.0.1:9582/redirect-no-fragment" }, + }); + + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "http://127.0.0.1:9582/redirect-target", frame.url); + } +} + test "cdp.frame: addScriptToEvaluateOnNewDocument" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/testing.zig b/src/testing.zig index e2500f81..73defeed 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -610,6 +610,32 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/redirect-no-fragment")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/redirect-target" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect-target")) { + return req.respond("landed", .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, + }, + }); + } + + if (std.mem.eql(u8, path, "/redirect-with-fragment")) { + return req.respond("", .{ + .status = .found, + .extra_headers = &.{ + .{ .name = "Location", .value = "/redirect-target#target_fragment" }, + }, + }); + } + if (std.mem.eql(u8, path, "/xhr/404")) { return req.respond("Not Found", .{ .status = .not_found,