Skip to content

Answer 404 for paths that match no page - #67

Merged
mspinola merged 1 commit into
mainfrom
claude/real-404-for-unknown-paths
Aug 25, 2026
Merged

Answer 404 for paths that match no page#67
mspinola merged 1 commit into
mainfrom
claude/real-404-for-unknown-paths

Conversation

@mspinola

Copy link
Copy Markdown
Owner

Why

use_pages=True registers a catch-all /<path:path> that serves the Dash index for any url and leaves the "page not found" screen to the client-side router. The HTTP status was therefore 200 no matter what was asked for.

One hour of this deployment's access log is a few hundred webshell probes — /shell.php, /wp-is.php, /404.php, /mail.php, /xxx.php — each answered 200 with a full 9.8 KB app shell.

Three costs, in increasing order of importance:

  1. Bandwidth.
  2. A visit log that cannot separate a real page view from a probe.
  3. A host that reads to a scanner as a live PHP target worth coming back to.

Ordering is the operational part

The guard is registered before record_visit. Flask runs before_request handlers in registration order and stops at the first that returns a response, so that position is what keeps a probe out of the visitor DB and out of the third-party geolocation lookup record_visit performs on every logged request — a lookup that is rate-limited at 45/min and was being spent on scanner traffic.

Structure

Membership and the 404 body live in src/routing.py, pure and importable without a Dash app or a store, following the split main.py already makes between check_price_store (I/O) and price_store_verdict (policy). The page registry and route list are read per request rather than captured at import, so a page added later is served without touching the guard.

Verification

Against the live app via the Flask test client:

  • All 16 real paths still 200, including the trailing-slash spelling (/exposure/) and the nested /citpy/view, plus Dash internals (/_dash-layout, /_dash-update-component) and both favicon paths.
  • All 9 probe paths now 404, including /exposure/../etc and /exposure/anything (a page prefix must not admit everything under it).
  • 404 body is 707 bytes against the shell's 9851.

Tests: 44 new (tests/test_routing.py), pinning membership in both directions — admit too much and the 200-to-everything problem returns; admit too little and a real page 404s, which is a worse outage than the one being fixed. Full suite 536 passed, ruff check src tests clean.

🤖 Generated with Claude Code

use_pages=True registers a catch-all /<path:path> that serves the Dash index for
ANY url and leaves the "page not found" screen to the client-side router, so the
HTTP status was 200 no matter what was asked for. One hour of this deployment's
access log is a few hundred webshell probes -- /shell.php, /wp-is.php, /404.php,
/mail.php -- each answered 200 with a full 9.8 KB app shell.

Three costs, in increasing order: bandwidth; a visit log that cannot separate a
page view from a probe; and a host that reads to a scanner as a live PHP target
worth coming back to.

The guard is registered BEFORE record_visit, which is the part that matters
operationally. Flask runs before_request handlers in registration order and stops
at the first that returns a response, so that position keeps a probe out of the
visitor DB and out of the third-party geolocation lookup record_visit performs on
every logged request -- a lookup that is rate-limited at 45/min and was being
spent on scanner traffic.

Membership and the body live in src/routing.py, pure and importable without a Dash
app or a store, following the split main.py already makes between check_price_store
and price_store_verdict. The page registry and route list are read per request
rather than captured at import, so a page added later is served without touching
the guard.

Verified against the live app: all 16 real paths (including the trailing-slash
spelling and nested /citpy/view) still 200, all 9 probe paths now 404, and the 404
body is 707 bytes against the shell's 9851.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mspinola
mspinola merged commit c3bf850 into main Aug 25, 2026
3 checks passed
@mspinola
mspinola deleted the claude/real-404-for-unknown-paths branch August 25, 2026 14:02
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