Skip to content

Security fixes & hardening: injection, open redirect, some DoS mitigations - #216

Open
fschwebel wants to merge 8 commits into
matze:masterfrom
fschwebel:security-fixes
Open

Security fixes & hardening: injection, open redirect, some DoS mitigations#216
fschwebel wants to merge 8 commits into
matze:masterfrom
fschwebel:security-fixes

Conversation

@fschwebel

Copy link
Copy Markdown

Fixes five security issues, one commit each with regression tests. 10 files,
+348/-17, 11 new tests. cargo test, clippy -Dwarnings, and fmt --check pass.

  • Escape long lines in the highlighter (highlight.rs) — lines over 2048 bytes
    skipped escaping and were rendered via {{ html|safe }}, injecting stored markup
    into every viewer's page. Now escaped like every other line.
  • Escape input on the QR and burn pages (qr.rs, burn.rs, i18n.rs,
    burn.html) — both templates used escape = "none", exposing the paste title and
    the unvalidated URL extension (the /burn/{id}.{ext} sink needs no real paste).
    Escaping restored; t_with now escapes its substituted value.
  • Reject unauthorized deletes with 403 (extract.rs, delete/*) — the Uids
    extractor rejected with (), i.e. an empty 200 OK, so a DELETE with a missing or
    forged cookie reported success while the paste survived (the default random signing
    key makes this the common case after any restart). Now a proper 403 via delete_for.
  • Cap a single cached render at 1 MiB (cache.rs) — a pathological paste renders
    to tens of MB and was cached across 128 slots (multi-GB worst case). Oversized
    renders are no longer cached.
  • Bound concurrent Argon2 with a semaphore (crypto.rs) — each password attempt
    reserves 64 MiB on the 512-thread blocking pool; concurrency is now capped at the
    core count, covering both encrypt and decrypt.

Made with Claude Fable 5

Lines longer than HIGHLIGHT_LINE_LENGTH_CUTOFF take a fast path that
skips highlighting. That path returned the raw source line, which is
then concatenated into the page and rendered with `{{ html|safe }}`, so
a paste containing one line over the cutoff injected arbitrary markup
into the page every viewer loads.

Escaping is what the highlighter's normal path does via
line_tokens_to_classed_spans; the cutoff was only ever meant to skip
tokenising, not escaping. Run the line through the existing escape()
helper instead.

The Content Security Policy prevents this from becoming XSS, but it does
not cover `meta refresh`, so the practical impact was navigating every
viewer of the paste to an arbitrary site, plus full control of the page
via a `data:` stylesheet.

As a side effect this also stops a long line containing literal `<span`
or `</span>` text from unbalancing the surrounding markup, since
open_span_prefix now never sees tags it did not emit.
Both templates were declared `escape = "none"`, and askama resolves
inheritance into the child, so escaping was also off for every block
they inherit from paste.html and base.html. Three sinks followed:

  * /qr/{id} rendered the paste title unescaped (stored);
  * /burn/{id}.{ext} put the unvalidated URL "extension" inside an href,
    and renders before touching the database, so a made-up id worked;
  * /qr/{id}.{ext} put the same value into data-paste-id.

qr.html only interpolates integers and UI strings, so it just gets
escaping back. burn.html genuinely needs raw markup for one translated
literal, so it follows the pattern burn-confirmation.html already uses:
escaping on, `safe` applied to the trusted string only. Since that
string interpolates an untrusted value, t_with() now escapes what it
substitutes.

The Content Security Policy kept this short of XSS, but `meta refresh`
is not covered by any directive, so a single crafted /burn/ link
redirected the visitor anywhere.

Adds regression tests for all three sinks and for t_with().
The `Uids` extractor used `type Rejection = ()`, and in axum `()` is a
valid response: an empty `200 OK`. So a DELETE whose `uid` cookie was
missing, forged, or signed with a key the server no longer holds never
reached the handler and the caller was told the delete succeeded — while
the paste stayed online. With `WASTEBIN_SIGNING_KEY` unset (the default)
a fresh key is generated at every restart, so ownership cookies stop
verifying after each restart, making this the common case rather than an
edge one.

Make the extractor yield an empty uid list instead of failing. An empty
list already means "no ownership proven": `Database::delete_for` returns
`Error::Delete` for it, which both delete routes render as 403 in their
own format (JSON for the API route, an HTML error page for the form
route). The optional `Uids` extractor used by the read handlers is
unaffected — an empty list yields `can_delete = false` exactly as a
missing cookie did before.

Adds regression tests covering a missing cookie and a forged cookie on
both delete routes.
Syntax highlighting wraps every line and token in markup, so a
pathological paste (e.g. a megabyte of newlines) renders to tens of
megabytes of HTML, and that result is cached. With the default cache of
128 entries, worst-case resident memory was in the multiple-gigabyte
range — reachable with a handful of ordinary-sized requests.

Refuse to cache any render larger than 1 MiB, bounding worst-case cache
memory to roughly WASTEBIN_CACHE_SIZE * 1 MiB. A paste whose highlighted
form exceeds the default request-body limit is unusual enough to
re-render on each view rather than let one entry dominate the cache;
correctness is unchanged either way.

This does not address a related lower-severity issue: because the cache
key includes the URL extension, one paste fetched under many extensions
still occupies many slots. With per-entry size now bounded that is a
cache-eviction concern, not a memory-exhaustion one, and is left as is.

Adds `Html::len`/`is_empty` and a test that an oversized render is not
cached.
Every password attempt derives an Argon2 key, and the configuration
reserves 64 MiB and keeps several cores busy per hash. Each derivation
runs on the tokio blocking pool via `spawn_blocking`, which defaults to
512 threads, so a burst of unauthenticated attempts (e.g. wrong-password
reads of an encrypted paste) could pin the machine's memory and CPU with
no bound of its own.

Gate the hashing behind a semaphore sized to the core count, acquired
before the blocking task and released after it, so concurrency is capped
independently of the blocking pool. This covers both encryption and
decryption, the only two Argon2 call sites.

Adds a concurrency test that more round-trips than there are permits all
complete, guarding against a leaked permit or deadlock.
The magic-link handoff redirected to `format!("/{id}")` using the raw,
percent-decoded path parameter before the id was validated. A value such
as `%5Cevil.example.com` decodes to `/\evil.example.com`, which a browser
normalises to `//evil.example.com` — an off-site redirect. An attacker
mints the required owner token by creating a paste of their own.

Parse the id first and build the redirect from the canonical `Key`. A
value that is not a valid paste id no longer triggers the handoff at all;
a valid one always begins with an id character (never `/` or `\`), so the
target stays same-origin. Adds a regression test.
SafeReferer guarded the relative-referer branch against `//` but not `/\`,
and the absolute-URL branch handed `url.path()` straight to Redirect
even though a path can itself begin with `//`. Either produced a
scheme-relative Location that leaves the origin: a page at a `//`-path with
`Referrer-Policy: unsafe-url` linking to `/theme` would bounce the visitor
off-site.

Route both branches through a single `safe_local_redirect` that only
accepts a single-slash absolute path (rejecting `//` and `/\`) and
otherwise falls back to `/`. Adds tests for the absolute `//`-path and the
`/\` relative referer.
WASTEBIN_PASTE_EXPIRATIONS only populated the index-page dropdown; both
insert paths accepted any NonZeroU32, or none at all, so an operator who
configured a mandatory retention policy did not actually get one.

Add Page::allows_expiration and check the requested value (0 meaning "no
expiration") against the configured set on both the JSON and form insert
handlers, rejecting anything outside it with 400 via a new
Error::IllegalExpiration. Adds tests on both routes.
@fschwebel

Copy link
Copy Markdown
Author

Follow-up: three more fixes pushed, addressing open redirects and enforcing the expiry
policy. Each is its own commit with tests; suite is now 89, clippy -Dwarnings and fmt still clean.

  • Owner-handoff open redirect (a3cff6e, was L-1) — the handoff redirected to
    format!("/{id}") using the raw path param before validation, so /%5Cevil.example.com
    became an off-site redirect once the browser normalised the backslash. Now parses the id
    and redirects to the canonical Key; a non-paste id doesn't redirect at all.
  • Referer open redirect (3b2fdf0, was L-2) — SafeReferer guarded the relative branch
    against // but not /\, and handed url.path() (which can start with //) straight to
    Redirect. Both branches now pass through one safe_local_redirect that accepts only a
    single-slash path and otherwise falls back to /.
  • Expiration set not enforced (55b62ee, was L-6) — WASTEBIN_PASTE_EXPIRATIONS only
    populated the dropdown; both insert paths accepted any value. Now validated against the
    configured set (0 = never) on both routes, rejecting anything else with 400.

Also Fable.

@fschwebel fschwebel changed the title Security fixes & hardening: injection, delete auth, and some DoS mitigations Security fixes & hardening: injection, open redirect, some DoS mitigations Aug 20, 2026
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