Skip to content

Harden the web server: bind to --hostname, add XSRF protection, validate /config paths - #116

Merged
kahne merged 3 commits into
mainfrom
harden-web-server-security
Aug 13, 2026
Merged

Harden the web server: bind to --hostname, add XSRF protection, validate /config paths#116
kahne merged 3 commits into
mainfrom
harden-web-server-security

Conversation

@kahne

@kahne kahne commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes three ways the local web server was more exposed than it needed to be.

Bind to --hostname. app.listen() was called without an address, so the server listened on every interface regardless of what --hostname said — the flag only affected the URL printed in the log. It now binds to the given host, which defaults to localhost. Pass --hostname 0.0.0.0 to get the old behavior deliberately.

XSRF protection. /upload, /config and /task_cfg mutate state with no authentication, so any page in the user's browser could POST to them. The app now enables Tornado's xsrf_cookies with samesite=Strict. A render_template() helper on the base handler embeds the token in every rendered page (reading self.xsrf_token is what sets the cookie); the upload form carries it in a hidden field, and an $.ajaxSetup hook in base.html attaches X-XSRFToken to same-origin, state-changing AJAX requests.

Validate /config paths. The POST handler accepted any path that passed os.path.exists, which made it a probe for arbitrary filesystem paths. It now routes through set_g_cred_path(), which requires a readable .json file, and applies the credentials to the running process so /g_translate picks them up without a restart. set_g_cred_path() also gained an explicit readability check.

Test plan

  • pytest tests/test_web.py — 11 passed, 6 subtests passed.
  • Existing web tests now forward the XSRF token, plus new coverage asserting that token-less POSTs to /upload, /config and /task_cfg are rejected with 403.

🤖 Generated with Claude Code

Changhan Wang and others added 2 commits August 12, 2026 12:39
…paths

The web app has three unauthenticated state-changing endpoints: /upload
writes and extracts a ZIP into --data-root, /task_cfg rewrites a task's
config, and /config persists a credential path. Three issues let those be
reached from outside the intended trust boundary.

Bind to the requested address
- app.listen() was called without `address`, so Tornado bound 0.0.0.0 and
  :: regardless of --hostname; the flag only affected the URL printed at
  startup. A server started with the localhost default was reachable from
  the whole network. Pass address=hostname, and document that 0.0.0.0 is
  the explicit opt-in to network exposure.

XSRF protection
- No xsrf_cookies, so any page the user visited could POST to the server.
  Enable it, with samesite='Strict' on the cookie so a cross-site request
  fails both on the missing cookie and the unknown token.
- The pages are rendered by Jinja2, not Tornado templates, so
  xsrf_form_html() is unavailable. Add VizSeqBaseRequestHandler.
  render_template(), which injects xsrf_token into every render (reading
  it is also what sets the cookie), and route the five GET handlers
  through it.
- base.html carries the token in a meta tag plus a $.ajaxSetup that sends
  X-XSRFToken on non-GET same-origin requests, covering both AJAX callers
  without touching either. upload.html gets a hidden _xsrf field for its
  multipart form.

Validate credential paths instead of probing them
- ConfigHandler.post reported op.exists() for any caller-supplied path,
  answering "does this file exist" for arbitrary locations. Use the
  existing _data.set_g_cred_path(), which requires an existing, readable,
  regular .json file, so the response no longer distinguishes present from
  absent except for .json files. Add the readability check to that helper;
  it validated exists/isfile/extension only.
- This also applies the credentials to the running process. The old code
  persisted the path but never called set_g_cred_path(), so
  GOOGLE_APPLICATION_CREDENTIALS was never set and /g_translate could not
  pick up a new credential without a restart.

Verified: the server accepts on loopback and is refused on this host's
routable address; /upload, /config and /task_cfg return 403 without a
token, and both the header and form-field token paths reach the real
handler logic with ZIP traversal rejection and cleanup unchanged;
/etc/passwd and /etc both report valid=false while a real .json file is
accepted, applied and persisted.

Known follow-up: tests/test_web.py's two upload tests predate XSRF and
POST without a token, so they now get 403 where they expect 400. They
need to GET /upload first and forward the hidden-field token and the
_xsrf cookie. Left untouched here.
The previous commit enabled xsrf_cookies but left tests/test_web.py alone,
noting it as a follow-up. Tornado runs check_xsrf_cookie() in _execute,
before the handler, so the two upload tests POSTed without a token, got 403
and never reached the validation they were written to exercise. Both
asserted 400.

Forward the token
- Add _get_xsrf_token(), which GETs /upload and parses _xsrf out of
  Set-Cookie. AsyncHTTPTestCase has no cookie jar, so the cookie has to be
  echoed back by hand; _xsrf_headers() builds that Cookie header.
- _multipart_zip() takes an optional token and emits it as an _xsrf form
  part, matching how upload.html actually submits, rather than the
  X-XSRFToken header path that base.html's $.ajaxSetup uses. Both reach the
  same check_xsrf_cookie(); the form field is the one this endpoint's own
  template relies on.
- Route the two upload tests through _post_zip(). Their assertions are
  unchanged: ZIP traversal and corrupt-archive handling are what they test,
  and they test it again now.

Cover the new behavior
- test_upload_accepts_a_valid_archive_with_an_xsrf_token: a tokened upload
  reaches the handler, redirects 303, unpacks, and removes the archive.
  Without this the suite would still pass if XSRF rejected everything.
- test_state_changing_posts_without_an_xsrf_token_are_rejected: /upload,
  /task_cfg and /config all return 403 untokened, and no data is written.

Verified: 11 passed. The pre-change file fails exactly two tests against
the current server, and flipping xsrf_cookies to False fails the new
rejection test, so it is not vacuously green.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 12, 2026
Comment thread vizseq/_data/google_translate.py Dismissed
The comment claimed set_g_cred_path()'s validation kept the endpoint from
doubling as a probe for arbitrary filesystem paths. That is backwards: the
validation constrains the file's kind (a readable .json) but not its
location, so the valid/invalid response is an existence oracle for readable
JSON files anywhere on disk.

Record what is actually true, why the path is left unconstrained (service
account credentials normally live outside --data-root), and why the oracle is
accepted rather than fixed (localhost-default bind plus XSRF). CodeQL alert
#15 (py/path-injection) is dismissed on the same rationale.
@kahne
kahne merged commit dea0312 into main Aug 13, 2026
19 checks passed
@kahne
kahne deleted the harden-web-server-security branch August 13, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants