Problem
canonicalizeUrl drops a trailing slash on any non-root path, so /a/ and /a become one cache
key. We then fetch, render and cache the slash-less form and serve those bytes for both — which
means we answer for a URL the origin never approved.
Measured against the live origin (bypass token), most recently 2026-08-13 across two passes ~20
minutes apart with two distinct URLs per shape:
| URL | origin |
|---|
/product/prd-<id>/<slug>.jsp | 200, 1 product |
/product/prd-<id>/<slug>.jsp/ | 403 |
/catalog.jsp?CN=<facets> | 200, 37 products |
/catalog.jsp/?CN=<facets> | 200 — but the 404 page (canonical /feature/404.jsp, 0 products) |
/catalog/<slug>.jsp?CN=<facets> | 200, 37 products |
/catalog/<slug>.jsp/?CN=<facets> | 200, identical to the slash-less form |
So on two of the four routed shapes the slashed URL is not the same resource. The product route
is a hard refusal (a WAF verdict on URL shape — the same class of rule as #81, not an auth failure).
/catalog.jsp/ answers with the site's 404 page under a 200 status, which is the more dangerous
of the two: a soft 404 is a 200, and a 200 is exactly what isPrerenderCandidate accepts. Only
/catalog/<slug>.jsp/ is genuinely equivalent.
A crawler that requests either non-equivalent form gets served our cached snapshot of a different
URL — one the origin answers 403 or 404 on.
Why this rule is different from the others
We serve on behalf of the origin, so every normalization rule is a claim about the origin's URL
semantics, and the requirement is that origin(N(U)) be the same resource as origin(U) for every
URL we accept. Most of the rules in canonicalizeUrl are settled at the parser boundary —
dropping the fragment, upper-casing escape hex, decoding %3A/%2C/%40, sorting params — where
the decoder erases the distinction before any application logic runs. Those hold for every URL and
can be established once.
Trailing slash is not like that. Whether /a/ and /a name one resource is a per-resource
question, decided by routing and by edge rules, and no parser property implies it. It is the one
rule in the set that cannot be justified by argument — and the probes above show it is in fact false
for two route classes on this deployment, including one that the first round of measurement (which
only covered /product/ and /catalog/<slug>) recorded as harmless.
Resolution
cacheKey.trailingSlash: 'strip' | 'preserve' shipped in plugin v0.45.0 / browser v1.17.0 (#94),
implemented in both canonicalizeUrl copies, defaulting to strip so an upgrade changes nothing,
with the measured 403 in the option's own description and pinned by shared test vectors.
This deployment is set to preserve — paired PRs, to be deployed in one window:
- component: HarperFast/kohls-pr#69
- render fleet: HarperFast/render-service#72
Both sides must move together: the fleet's mirrored cacheKey block decides which URLs are the same
key.
Properties of the change:
- No migration. Stored keys are already slash-less, since every sitemap loc and every canonical
the origin emits is slash-less. Nothing re-keys. - Self-correcting. A slashed URL keys separately, is discovered only if the origin answers it
with a 200 (so on the product route it never becomes a target at all), and any that do slip
through render, take the 403, and retire as http-error. - The right answer for the crawler. It gets what the origin actually says for the URL it asked
for.
Open — the cost side is still unmeasured
How many slashed requests actually arrive (i.e. how many cache hits become origin proxies) is not
known, and could not be measured: HTTP request logging produces no output on these nodes. The
configured http.logging.path creates no http.log file, and neither hdb.log nor container stdout
carries request lines — so there is no per-request record to count. Sizing this needs request logging
working first, and that is worth fixing independently of this issue.
The exposure is bounded regardless: slashed URLs are a crawler artifact, appearing in neither the
sitemap nor the origin's canonicals.
Caveat
Dropping the fold slightly widens the discovery surface (/a/ becomes eligible to be its own target
where the origin does answer 200 for both). The /catalog.jsp/ soft 404 is exactly that shape — it
returns 200, so it can become a target that renders the 404 page — which makes this an argument for
pairing with the discovery-side validation in #81 / the bot-name gate in #84.
Refs #84, #92, #94
Problem
canonicalizeUrldrops a trailing slash on any non-root path, so/a/and/abecome one cachekey. We then fetch, render and cache the slash-less form and serve those bytes for both — which
means we answer for a URL the origin never approved.
Measured against the live origin (bypass token), most recently 2026-08-13 across two passes ~20
minutes apart with two distinct URLs per shape:
/product/prd-<id>/<slug>.jsp/product/prd-<id>/<slug>.jsp//catalog.jsp?CN=<facets>/catalog.jsp/?CN=<facets>/feature/404.jsp, 0 products)/catalog/<slug>.jsp?CN=<facets>/catalog/<slug>.jsp/?CN=<facets>So on two of the four routed shapes the slashed URL is not the same resource. The product route
is a hard refusal (a WAF verdict on URL shape — the same class of rule as #81, not an auth failure).
/catalog.jsp/answers with the site's 404 page under a200status, which is the more dangerousof the two: a soft 404 is a
200, and a200is exactly whatisPrerenderCandidateaccepts. Only/catalog/<slug>.jsp/is genuinely equivalent.A crawler that requests either non-equivalent form gets served our cached snapshot of a different
URL — one the origin answers
403or404on.Why this rule is different from the others
We serve on behalf of the origin, so every normalization rule is a claim about the origin's URL
semantics, and the requirement is that
origin(N(U))be the same resource asorigin(U)for everyURL we accept. Most of the rules in
canonicalizeUrlare settled at the parser boundary —dropping the fragment, upper-casing escape hex, decoding
%3A/%2C/%40, sorting params — wherethe decoder erases the distinction before any application logic runs. Those hold for every URL and
can be established once.
Trailing slash is not like that. Whether
/a/and/aname one resource is a per-resourcequestion, decided by routing and by edge rules, and no parser property implies it. It is the one
rule in the set that cannot be justified by argument — and the probes above show it is in fact false
for two route classes on this deployment, including one that the first round of measurement (which
only covered
/product/and/catalog/<slug>) recorded as harmless.Resolution
cacheKey.trailingSlash: 'strip' | 'preserve'shipped in plugin v0.45.0 / browser v1.17.0 (#94),implemented in both
canonicalizeUrlcopies, defaulting tostripso an upgrade changes nothing,with the measured
403in the option's own description and pinned by shared test vectors.This deployment is set to
preserve— paired PRs, to be deployed in one window:Both sides must move together: the fleet's mirrored
cacheKeyblock decides which URLs are the samekey.
Properties of the change:
the origin emits is slash-less. Nothing re-keys.
with a
200(so on the product route it never becomes a target at all), and any that do slipthrough render, take the
403, and retire ashttp-error.for.
Open — the cost side is still unmeasured
How many slashed requests actually arrive (i.e. how many cache hits become origin proxies) is not
known, and could not be measured: HTTP request logging produces no output on these nodes. The
configured
http.logging.pathcreates nohttp.logfile, and neitherhdb.lognor container stdoutcarries request lines — so there is no per-request record to count. Sizing this needs request logging
working first, and that is worth fixing independently of this issue.
The exposure is bounded regardless: slashed URLs are a crawler artifact, appearing in neither the
sitemap nor the origin's canonicals.
Caveat
Dropping the fold slightly widens the discovery surface (
/a/becomes eligible to be its own targetwhere the origin does answer
200for both). The/catalog.jsp/soft 404 is exactly that shape — itreturns
200, so it can become a target that renders the 404 page — which makes this an argument forpairing with the discovery-side validation in #81 / the bot-name gate in #84.
Refs #84, #92, #94