Skip to content

Trailing-slash folding serves pages the origin refuses (PDP 403, /catalog.jsp/ soft 404) #93

Description

@harper-joseph

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:

URLorigin
/product/prd-<id>/<slug>.jsp200, 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions