http: inherit request URL fragment across fragment-less redirect

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
This commit is contained in:
Navid EMAD
2026-04-27 08:04:47 +02:00
parent 9fbade4573
commit 00c42dec4e
3 changed files with 93 additions and 1 deletions

View File

@@ -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);

View File

@@ -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();

View File

@@ -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("<!DOCTYPE html><title>landed</title>", .{
.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,