Skip to content

SvelteKit: Markdown link to literal "null" becomes relative URL causing 404s

TL;DR.

LLM- or JSON-derived markdown containing [text](null) renders as a schemeless <a href="null">, which crawlers resolve against the page URL and request as a real path — one bad string yields three distinct 404 shapes and, on a view route, three upstream API calls per hit. Sentry filters 404s, so the referrer needed to find the emitting page is only recoverable from the request headers of a co-occurring non-404 event.

Symptom

Origin logs show 404s for paths that no template could plausibly build:

Not found: /{user}/views/{real-slug}/null     (x24)
Not found: /{user}/views/null                 (x1, this one cost 3 upstream API calls)
Not found: /null                              (x1)

Every URL builder in the codebase interpolates a non-nullable field, and grepping the rendered HTML of every page in the sitemap finds no href="null". Both observations are consistent and both are red herrings.

Cause

The three shapes are one bad href, resolved three ways. <a href="null"> is a schemeless relative reference:

  • RFC 3986 resolution against /u/views/slug replaces the last segment -> /u/views/null
  • a naive crawler doing base + "/" + href -> /u/views/slug/null
  • the same href on a single-segment page -> /null

So one occurrence in shared or generated content explains an entire scattered 404 family. Rendered-HTML greps come up empty because the offending value is data, not code: it lives in a generated content field, and the field gets regenerated, so it is gone hours later while the 404s persist in the logs.

Two mechanics make the literal string "null" (not None, not undefined) the one that escapes:

  1. Framework null-omission gives false confidence. Svelte drops href entirely for null/undefined (<a href={x}>), and a component like <svelte:element this={href ? 'a' : 'button'}> degrades to a non-link. Only a stringified null survives — which is exactly what ${x} interpolation, JSON.stringify(null), or an LLM writing null into a URL slot produces.
  2. Template guards are usually on the label, not the URL. A generated attribution block of the form {?title}[{title}]({url}){/title} emits a link whenever the title is present. Verified in an ashes/dust template: a Python None url renders [T]() (harmless, resolves to the current page), while the string 'null' renders [T](null) (a real 404 generator). And oembed.get('author_url', '') does not apply its default to an explicit JSON null, so third-party nulls flow straight through.

Worse, markdown post-processors often adopt schemeless hrefs as external instead of rejecting them — e.g. html.replace(/<a href="(?!\/)/g, '<a target="_blank" href="') happily marks href="null" as an external link.

Why tracing it is hard, and the one place the referrer survives

SvelteKit's unmatched-route 404 logs Error: Not found: <path> with no request context. handleError is invoked for it — verified in @sveltejs/kit@2.69.1: src/runtime/server/respond.js:707-718 constructs new SvelteKitError(404, 'Not Found', ...) and calls respond_with_error, which calls handle_error_and_jsonify -> options.hooks.handleError (src/runtime/server/utils.js:102-120). But the Sentry SvelteKit SDK filters 404s, so no issue exists to inspect.

The referrer was recoverable only because the same bad URL also produced a non-404 event: the SSR upstream timeout captured from handleFetch. Pull that event's request entry (not its tags) and the headers are there:

URL:     https://site/{user}/views/null
Referer: https://site/{user}/views/{real-slug}   <- the emitting page
UA:      meta-externalagent/1.1                  <- non-JS crawler, so it read SSR HTML

Fixes

  1. Reject schemeless hrefs at the markdown chokepoint, don't adopt them. Allow https?:, mailto:, /-rooted and # fragments; strip the href attribute otherwise so the text stays and the link dies. With sanitize-html this is a transformTags.a entry, and it must run inside sanitization (which sees parsed attribute values) rather than as a later regex. One rule retroactively neutralizes every already-stored bad string, with no data migration.
  2. Validate at generation time too, and degrade to plain text rather than dropping the credit: f"[{name}]({url})" if url_is_ok else name.
  3. Make the fallout free. In SvelteKit, a param matcher (src/params/*.js) runs before any load, so a rejection costs zero backend calls. Reject the stringified-empty family (null, undefined, none, nan) and mirror your writer's charset validator. Before this, each junk hit fanned out to three API calls.
  4. Log 404s that carry a Referer — one JSON line with path, route (null for matcher rejections, set when a load threw), referer, ua. Requests without a referrer are scanner noise; requests with one are self-inflicted and name the page to fix.

Transferable rule

Any user- or model-authored string that reaches an href must be scheme-checked, because a relative href is a request against your own origin. Framework null-omission protects you from real nulls and not at all from the string "null" — which is the form every interpolation, JSON round-trip and LLM produces.

No signals yet