Skip to content

Strip the fragment from the rendered request-target - #36

Merged
hellerve merged 1 commit into
masterfrom
claude/target-strip-fragment
Aug 21, 2026
Merged

Strip the fragment from the rendered request-target#36
hellerve merged 1 commit into
masterfrom
claude/target-strip-fragment

Conversation

@carpentry-agent

Copy link
Copy Markdown

Request.target strips userinfo (#35) but renders the rest of the URI as it stands, so a fragment goes out on the wire in the request line. RFC 9110 §7.1: "The target URI excludes the reference's fragment component, if any, since fragment identifiers are reserved for client-side processing."

Verified against master before touching anything:

URI request line on master
http://h.example/a/b?q=1#sec2 GET http://h.example/a/b?q=1#sec2 HTTP/1.1
http://h.example/a/b#sec2 GET http://h.example/a/b#sec2 HTTP/1.1
http://h.example/a/b# GET http://h.example/a/b# HTTP/1.1
http://h.example/a/b#frag?notquery GET http://h.example/a/b#frag?notquery HTTP/1.1

The last row is the sharp one: a ? (or /) inside a fragment reaches the server looking like part of the query or the path, and the request line is one of the most routinely logged parts of a request.

The change

One more URI.set-fragment … (Maybe.Nothing) in the let that already drops user and password. It rides on the same copy, so the stored URI is untouched and a caller can still read the fragment off Request.uri. The empty-path special case now sees the stripped URI, which gives a fragment-only URI a / target instead of the #sec2 it rendered before.

Tests

Eight new assertions: the four rows above, two controls for a # that must survive (percent-encoded %23 in a query from the parser, and a literal # set straight into the query component — the latter pins that the strip is component-based, not a scan of the rendered string), the fragment-only-URI / case, and the fragment still being readable off Request.uri.

Teeth checked by reverting http.carp alone: 5 of the 8 fail, the 2 #-in-query controls and the read-back stay green. Full suite is 460 passing. carp-fmt and angler were run from binaries built fresh at HEAD, since both tools shipped changes today that the local installs predate.

Deliberately out of scope: test/http.carp pins absolute-form request-target is left alone, so this changes the fragment and nothing else about the form of the target.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

RFC 9110 §7.1: "The target URI excludes the reference's fragment
component, if any, since fragment identifiers are reserved for
client-side processing."

`Request.target` stripped userinfo but rendered the rest of the URI as
it stood, so a request built from a URI carrying a fragment sent it on
the wire — and therefore into every request-line log on the way:

    http://h.example/a/b?q=1#sec2      -> GET http://h.example/a/b?q=1#sec2 HTTP/1.1
    http://h.example/a/b#sec2          -> GET http://h.example/a/b#sec2 HTTP/1.1
    http://h.example/a/b#              -> GET http://h.example/a/b# HTTP/1.1
    http://h.example/a/b#frag?notquery -> GET http://h.example/a/b#frag?notquery HTTP/1.1

The last one is the sharp case: a `?` or `/` inside a fragment reaches
the server looking like part of the query or the path.

The strip rides on the copy the userinfo strip already makes, so the
fragment stays on the request's URI for a caller that wants to read it
back, and the empty-path special case sees the stripped URI — which
gives a fragment-only URI a `/` target instead of the `#sec2` it
rendered before.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out 78c1ed4. Merge-base is 629d13a = current origin/master, so no stale-branch drift.

  • carp -x test/http.carp460 passed, 0 failed, rc 0. Matches the description.
  • CI — test (ubuntu-latest) and test (macos-latest) both pass. This repo gates Run tests, Lint (angler), Format check (carp-fmt) and Generate docs on both runners, so the "built fresh at HEAD" tool claims in the body are CI-gated and I did not re-run them.
  • carp -x gendocs.carp leaves the tree clean. target is private + hidden with exactly one caller (Request.str, http.carp:335), so there is no documentation surface here either way. No CHANGELOG in this repo, so correctly none added.

Teeth, verified rather than taken. Reverting http.carp alone and leaving the tests: 5 of 8 fail, and the ones that stay green are precisely the two #-in-query controls and the fragment read-back. That is the claim in the body, row for row.

Findings

1. A fragment folded into opaque still reaches the wire

mailto: and urn: targets keep their #:

mailto:x@y#f   ->  GET mailto:x@y#f HTTP/1.1
urn:a:b#f      ->  GET urn:a:b#f HTTP/1.1

The cause is upstream, not here. For a URI with no // after the scheme, uri's parse-scheme stores everything after the colon as opaqueopaque = "x@y#f", fragment = Nothing — so URI.set-fragment … Nothing has nothing to clear and URI.str renders the # straight out of opaque. RFC 3986 §3 puts the fragment outside the scheme-specific part, so that is a uri parse bug and the right place to fix it is carpentry-org/uri.

I am explicitly not suggesting target compensate. Scrubbing a # out of the rendered string is the textual approach your own control assertion (request-target keeps a literal # inside the query) exists to rule out, and it would be wrong for the same reason. This is worth recording as a limit of the change rather than acted on: the strip is complete for every hierarchical URI, and cannot reach a fragment the parser never separated. A mailto: request target is not a real client input, so the practical impact is nil.

2. Nothing else — here is what I did to look

A request-line differential against master, on a corpus of my own. 33 URIs through Request.str, both trees built from the same probe:

  • 22 rows move. Every one removes the fragment and changes nothing else — I diffed the whole line, not just the presence of #.
  • 11 rows are unchanged, and they are the ones that should be: ?q=%23notfrag and ?q=a%23b (a # that must survive because it is encoded), the fragmentless URIs, /, ?q=1, and the two opaque rows above.
  • Zero hierarchical rows still emit #.
  • The fragment is still readable off Request.uri on all 33 — that output is byte-identical between master and the branch, so the copy really is local to target.

Rows worth calling out beyond the body's table, because they are the ones where a fragment does the most damage if it escapes:

request URI master branch
http://h.example/a/b#a/../../etc GET http://h.example/a/b#a/../../etc GET http://h.example/a/b
http://h/a%20b#f%20g GET http://h/a%20b#f%20g GET http://h/a%20b
http://h/a?#f GET http://h/a?#f GET http://h/a?
#f GET /#f GET /

The #f row is the empty-path special case seeing the stripped URI, as the body says — though the before value is /#f, not #f: the all-Nothing guard already fired on master and prepended the slash.

The strip is component-based, and I checked it can't be fooled. set-fragment runs on the same copy that already drops user and password, and the ordering of the three setters is irrelevant. http://USER:PW@h.example/a/b#f loses both the credentials and the fragment in one line, and neither is readable back off the wire while both stay on Request.uri.

http-client is covered. It sends through Request.str (http-client.carp:273) rather than assembling its own request line, so the fix reaches every redirect-followed request, not just direct Request users.

3. Forward-looking: this and uri #36 both land on target

uri #36 changes the representation of URI.path, and target's empty-path guard reads that field, so I measured the pair rather than reasoning about them. I rebuilt http at this head with carp -x against uri #36's main.carp in place of the uri@0.5.1 pin:

  • carp -x test/http.carp — 460 passed, 0 failed. Nothing in this suite moves under the new uri.
  • The fragment strip still works on every shape only reachable with uri #36: http://h//a/b#fGET http://h//a/b, http://h//#fGET http://h//, ///x#fGET ///x, file:///etc/hosts#fGET file:///etc/hosts, http://h/a//b?q#fGET http://h/a//b?q.
  • Exactly one row in my corpus moves when the pin bumps, and it is uri's break rather than this PR's: a/b as a request URI goes from GET /a/b HTTP/1.1 to GET a/b HTTP/1.1, because str stops repairing a relative path and target's guard only fires when path is Nothing. I have raised that on uri #36; noting it here only so whoever bumps the pin knows the request-target tests were re-run and this is the single row to expect.

Verdict: merge

RFC 9110 §7.1 is unambiguous, the change is the minimal one that satisfies it, and it strips a component rather than scrubbing a string — which is the distinction your two #-in-query controls exist to protect and which they do protect. Tests, lint, format and docs are green on both runners, the eight new assertions have teeth in exactly the shape claimed, Request.uri is provably untouched, and I could not find a hierarchical URI that still leaks a fragment. The one residual # is a uri parse bug reached through opaque, out of this PR's reach and not worth widening its scope for.

@hellerve
hellerve merged commit f5f8f29 into master Aug 21, 2026
2 checks passed
@hellerve
hellerve deleted the claude/target-strip-fragment branch August 21, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant