Skip to content

fix: reject path traversal in the post id route - #28

Merged
du82 merged 1 commit into
du82:mainfrom
netqo:fix/path-traversal-post-id
Sep 5, 2026
Merged

fix: reject path traversal in the post id route#28
du82 merged 1 commit into
du82:mainfrom
netqo:fix/path-traversal-post-id

Conversation

@netqo

@netqo netqo commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #27

What

GET /<post_id> (and, by delegation, GET /nojs/<post_id>) interpolated the
URL path parameter straight into content/{post_id}.md with no validation.
Rocket percent-decodes dynamic path segments before the handler runs, so an
encoded ..%2f escaped the content/ directory and let any .md file on the
host be read over HTTP. Confirmed against a proxy-less instance; a
path-normalizing reverse proxy (e.g. Caddy in front of nonogra.ph) happens to
mitigate it, but a directly exposed Rocket -- including the onion service in the
shipped Dockerfile -- is fully affected.

How

  • Add is_valid_post_id, which accepts only the [A-Za-z0-9_-] slugs the
    application already produces (generate_post_id emits [a-z0-9-], the static
    pages are lowercase words, and the Telegraph archiver yields
    [A-Za-z0-9_-]). Because ., /, and \ are unrepresentable, a traversal
    sequence can no longer be formed.
  • Enforce it at the request boundary in view_post, before any filesystem
    access, returning the normal 404 for anything invalid.

The check is applied once, at the trust boundary between untrusted path input
and the filesystem, rather than scattered across call sites.

Tests

Added unit tests covering:

  • accepted slugs (generated posts, static pages, archiver output);
  • rejected traversal vectors (.., ../README, ..\README, foo/bar,
    foo.bar, post.md, embedded NUL, empty);
  • the length bound;
  • the invariant that every id generate_post_id can emit passes the guard, so
    no freshly created post 404s.

Full suite: cargo test -> 144 passed. Manually verified against a running
instance that traversal now returns 404 on all vectors (including /nojs),
while normal pages, raw .md reads, and a round-tripped published post still
return 200.

References

view_post interpolated the URL path parameter straight into
`content/{post_id}.md` with no validation. Rocket percent-decodes
dynamic path segments before the handler sees them, so an encoded
`..%2f` escaped the content directory and let any `.md` file on the
host be read over HTTP. The rendered branch and `GET /nojs/<post_id>`
were affected too, since both reach the same handler.

Add `is_valid_post_id`, which accepts only the `[A-Za-z0-9_-]` slugs
the application actually produces, and reject anything else at the
request boundary before any filesystem access. Because `.`, `/`, and
`\` are unrepresentable, a traversal sequence can no longer be formed.

Add unit tests covering accepted slugs, rejected traversal vectors,
the length bound, and the invariant that every generated id passes the
guard.

Closes du82#27
@du82

du82 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Will pay bounty to address in this comment: #31 (comment)

@du82
du82 merged commit be4a046 into du82:main Sep 5, 2026
2 checks passed
Zhiyilang074811 added a commit to Zhiyilang074811/nonograph that referenced this pull request Sep 7, 2026
Add is_valid_post_id() to validate post_id at the trust boundary
before any filesystem access. Only allows [a-z0-9_-] characters,
matching the exact charset that generate_post_id produces.

Closes du82#28
Refs: CWE-22, OWASP Path Traversal
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Path traversal in GET /<post_id> allows reading arbitrary .md files outside content/

2 participants