Skip to content

fix!: URLFor escapes each value for the part of the URL it fills - #38

Merged
jackielii merged 2 commits into
mainfrom
fix/urlfor-escape-by-part
Oct 21, 2026
Merged

jackielii merged 2 commits into
mainfrom
fix/urlfor-escape-by-part

Conversation

@jackielii

Copy link
Copy Markdown
Owner

Problem

URLFor path-escaped every placeholder value, including placeholders after ? or # in a composed template like []any{Page{}, "?q={q}"}. url.PathEscape leaves &, = and + alone, so no input round-tripped:

value passed raw passed url.QueryEscaped
A&B q=A&B → A + stray key B q=A%2526B → A%26B
c+d q=c+d → c d q=c%252Bd → c%2Bd

The old test at url_for_test.go:1237 locked in /search?q=foo&bar&filter=a=b, which parses to three keys.

Changes

Runtime (fix!)

  • parseSegments records whether each segment is in the path, query or fragment. The part switches only at the template's own first ? / #, never at one inside a value.
  • Values are held unescaped and escaped once in joinSegments:
    • path values exactly as before (PathEscape; wildcards keep their slashes), so path-only URLs from passed values are byte-identical;
    • query and fragment values as one query component (QueryEscape, with + written as %20, which is exact since a literal + becomes %2B).
  • Values auto-filled from the current request (r.PathValue, already decoded) were inserted unescaped, so a/b?c became two segments and a query. They're now escaped like passed values.
  • Only Mount fills the segment cache now, once per route. URLFor used to cache every pattern it saw, so a per-request template ("?" + v.Encode(), which the docs now recommend) grew it without bound. Found in review.

Lint (feat(lint))

  • New escape category flags url.QueryEscape, url.PathEscape and url.Values.Encode() passed as URLFor values, whether positional, key/value or in a literal map. It also covers gsx url(...) through the generated code.
  • On EOS it reports exactly one site: alerts.gsx:40.

Docs: docs/urlfor.md, docs/lint.md, skills/structpages/reference.md and the URLFor godoc.

Breaking

Pass placeholder values raw.

  • A value pre-escaped with url.QueryEscape now reads back escaped. The escape lint finds these.
  • A placeholder can no longer inject raw query text ("?{extra}" with "&sort=asc"). Put that text in the template, e.g. []any{page, "?" + v.Encode()}.

Unchanged on purpose: the documented arg detection where as many args as placeholders means positional.

Tests

  • url_for_escape_test.go has round-trip tests that parse the generated URL back and compare decoded parts. They cover query values, keys and partial values, path, fragment, and mixed templates; every arg form; values from the request including under WithURLPrefix; and the cache bound. 16 of them fail on main, and the cache test fails if URLFor caches again.
  • tools/lint/escape_integration_test.go covers flagged and allowed forms, including sp.URLFor and a suppressed line.
  • go test ./... passes and golangci-lint is clean.
  • In tools/lint, TestRegion_Integration fails on main too (missing templ findings), unrelated.

An independent review found no correctness bugs in the escaping. Its one medium finding, the cache growth, is fixed here.

https://claude.ai/code/session_01NpjJeP32ycvAHNFXRgTviG

URLFor path-escaped every placeholder value, including ones after ? or #
in a composed template such as []any{Page{}, "?q={q}"}. url.PathEscape
leaves &, = and + alone, so "A&B" broke the query, and a value escaped
first with url.QueryEscape came out escaped twice: no input round-tripped.

parseSegments now records whether each segment sits in the path, query
or fragment, switching only at the template's own first ? and #. Values
are held unescaped and escaped once when the URL is joined: path values
as before (wildcards keep their slashes), query and fragment values as
one query component, with spaces as %20 so existing URLs keep their form.

Values auto-filled from the current request come from r.PathValue,
already decoded, and were inserted unescaped: an id of "a/b?c" became
two path segments and a query. They are now escaped like passed values.

The segment cache is now filled only at Mount, once per route. URLFor
used to cache every pattern it saw, so a template composed per request
("?" + v.Encode()) grew the cache without bound.

BREAKING CHANGE: pass placeholder values raw. A value escaped with
url.QueryEscape is now escaped again and reads back escaped, and a
placeholder can no longer inject raw query text such as "&sort=asc";
put that text in the template instead.

Claude-Session: https://claude.ai/code/session_01NpjJeP32ycvAHNFXRgTviG
The new escape category reports url.QueryEscape, url.PathEscape and
url.Values.Encode calls passed as URLFor values (positional, key/value
or in a literal map), which URLFor now escapes again. It needs no page
tree, so it runs outside the per-tree check, and covers gsx url filters
through the generated code.

Claude-Session: https://claude.ai/code/session_01NpjJeP32ycvAHNFXRgTviG
@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.22%. Comparing base (97b0f9d) to head (1ded9dc).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #38      +/-   ##
==========================================
- Coverage   95.32%   95.22%   -0.10%     
==========================================
  Files          17       17              
  Lines        1754     1761       +7     
==========================================
+ Hits         1672     1677       +5     
- Misses         49       50       +1     
- Partials       33       34       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jackielii
jackielii merged commit c9d3fa7 into main Oct 21, 2026
16 checks passed
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