Skip to content

Plug security holes and redeploy the public demo - #113

Merged
Lauenburg merged 16 commits into
mainfrom
fix/security
Oct 7, 2026
Merged

Lauenburg merged 16 commits into
mainfrom
fix/security

Conversation

@Lauenburg

Copy link
Copy Markdown
Collaborator

Plug security holes and redeploy the public demo

The public demo (README links) was unreachable: its links pointed at an IP that AWS released when the Auto Scaling group replaced the instance in May 2026. The demo now has an Elastic IP and runs this branch.

Security fixes

A new env flag SYNANNO_PUBLIC_DEMO (off by default, on in the deployed task) locks the app down to the bundled H01 data.

Before After
/load_materialization Any path or URL went to pd.read_csv (file read, SSRF); errors echoed exception text Demo: only the bundled H01 table. Local: paths under SYNANNO_DATA_DIRS (default h01/) and http(s) URLs. 400 otherwise, checked before the cached-table shortcut; no exception text in responses
Credentials / buckets Uploaded bucket secrets stayed in shared memory Demo: credential uploads and non-H01 volumes get 403 before any state changes
Neuroglancer token randint(0, 3200) secrets.token_urlsafe(32)
CORS CORS(app) + @cross_origin on every route Removed; nothing needs it. flask-cors dropped
Debug run.py ran debug=True on 0.0.0.0; DEBUG_APP defaulted to True run.py binds 127.0.0.1, debug only with FLASK_DEBUG=1; DEBUG_APP defaults off; uWSGI never debugs
Secret key None SECRET_KEY from env, else random per start with a warning
Templates os exposed to Jinja Removed
jQuery 2.1.3 from CDN (XSS CVEs) 3.7.1 vendored (hash matches official SRI); .load(fn) → .on("load", fn)
File routes — Already safe (serve from memory); tests added
Input validation calculate_crop_pad validated a user bbox with assert; a rejected box still changed shared state ValueError → 400 from /ng_bbox_fn_save, shared state restored
Canvas uploads Any image format Pillow can decode, any size PNG only (closes reachable Pillow decoder advisories), max 4096 px per side, checked before decoding
Dependencies tornado 6.5.1 on the public Neuroglancer port tornado 6.5.10 (HTTP-parsing DoS fixes)

Build

  • requirements.lock = pip freeze of the image that ran before, minus the app's editable install and flask-cors, with tornado bumped.
  • Dockerfile_uwsgi: base pinned to python:3.11.13-bookworm, pip 25.2, installs the lock with --no-deps: torch/torchvision only from the PyTorch CPU index, everything else only from PyPI. apt packages and rustup stay unpinned.
  • The new image's pip freeze matches the lock exactly.

Tests

  • tests/test_security.py: 13 tests, one per fix (variants parametrized), plus the 3 existing ones; all 16 pass. Against main, all 11 fix tests fail; the 2 file-route tests pass there too (those routes were already safe).
  • scripts/smoke_test.sh <demo URL> against the live demo: all 11 checks pass (pages load; Neuroglancer served from <host>:9015 with a 43-char token; /etc/passwd, file:///etc/passwd and an external URL rejected with 400; no CORS header; credential upload 403). Against the previous image it fails the 6 security checks.
  • Browser test of the live demo in headless Chromium: tiles load, labeling, detail view, Neuroglancer view (renders H01 with meshes), categorization, redraw, drawing a mask + U-Net auto-complete, JSON export with the chosen flags. No JavaScript errors with jQuery 3.7.1.

Deployment changes

  • The demo has an Elastic IP; the README links point to it.
  • New task definition revision: same as before except the image, PUBLIC_DNS_SYNANNO (the Elastic IP) and SYNANNO_PUBLIC_DEMO=1. A separate rollback revision pins the previous image by digest.
  • Service deploys with min healthy 0% / max 100% (host networking cannot run two copies); Availability Zone rebalancing disabled because it requires max > 100%.
  • Firewall: SSH (22) is no longer open to the world; only 80 and 9015 are.
  • docs/deploy.md describes releases, rollback, re-associating the Elastic IP after an instance replacement, and temporary SSH access. It uses placeholders; concrete account and resource IDs are kept out of the repo.

Known issues not fixed here

  • All visitors share one app state, and /reset and /demo wipe it (by design: single-user test deployment).
  • HTTP only.
  • The Elastic IP must be re-associated by hand if the Auto Scaling group replaces the instance.
  • A second, older deployment in another AWS region still runs the unfixed code; its removal is pending.
  • Intermittent hang: in repeated scripted runs, /update_image_tiles/1 sometimes never reaches the Flask worker (seen on old and new images alike; not with curl or in the browser test). Not yet diagnosed.
  • Remaining pip-audit advisories in packages no request reaches (outbound clients to Google Cloud, decompression, the bundled torch checkpoint): urllib3, requests, httplib2, brotli, pyasn1, protobuf, fsspec, orjson, torch, plus moderate ones in Flask/Werkzeug/Jinja2. bandit has no high findings; the mediums are intended 0.0.0.0 binds in the container, the fixed /tmp/flask_session dir, and torch.load of the bundled checkpoint.
  • /load_materialization returns 500 once a table is cached (pre-existing DataFrame truthiness bug); bad paths are rejected before that.

Lauenburg and others added 16 commits October 7, 2026 13:19
- Add SYNANNO_PUBLIC_DEMO (off by default). In demo mode,
  /load_materialization accepts only the bundled H01 table, and /upload
  and /launch_neuroglancer reject credential uploads and any volumes
  other than the public H01 release with 403.
- Outside demo mode, /load_materialization accepts only paths under
  SYNANNO_DATA_DIRS (default: h01/) and http(s) URLs. Validation runs
  before the cached-table check, and errors no longer echo exception text.
- Remove CORS: no front-end code makes cross-origin requests to Flask.
- run.py binds to 127.0.0.1 and enables the debugger only with
  FLASK_DEBUG=1; DEBUG_APP defaults to off.
- Read SECRET_KEY from the environment, or generate one with a warning.
- Stop exposing the os module to Jinja templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The token was randint(0, 3200), so anyone could enumerate /v/<token>/ on
the public port 9015. Use secrets.token_urlsafe(32) instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jQuery 2.1.3 has known XSS issues (CVE-2015-9251, CVE-2019-11358,
CVE-2020-11022, CVE-2020-11023). Serve 3.7.1 from static/vendor
(sha256 matches the official SRI hash) and replace the .load(handler)
shorthand removed in jQuery 3 with .on("load", handler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The file routes serve images only from in-memory dicts keyed by the URL
segments, so they never touch the filesystem. Lock that in with tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
calculate_crop_pad used assert to reject invalid bounding boxes, which
python -O strips. Raise ValueError instead, and have /ng_bbox_fn_save
return 400 for missing or invalid coordinates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
requirements.lock is pip freeze from the running image
(synanno/uwsgi:rollback-2026-10), minus the app's own editable install
and flask-cors. The Dockerfile pins the base image to the same Python
(3.11.13, bookworm) and pip, and installs the lock with --no-deps, taking
the CPU-only torch build from the PyTorch index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Decode canvas uploads as PNG only. /save_canvas passed user-supplied
  base64 to Image.open, which exposed every Pillow decoder (PSD, FITS,
  PDF, JPEG2000, font files) and their open advisories. Non-PNG input now
  returns 400.
- Raise tornado from 6.5.1 to 6.5.10. It serves Neuroglancer on the
  public port 9015, and 6.5.1 has several HTTP-parsing DoS advisories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first line of the pip freeze output was cut off when the lock was
written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/smoke_test.sh <URL> checks that the demo pages load, that the
Neuroglancer view is served from <host>:9015 with a long token, that
/load_materialization rejects local files and external URLs, that no
CORS header is sent, and that credential uploads are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge variants into single or parametrized tests and drop checks that
guarded no change (the tornado pin, the vendored file's header, extra
file-route paths). 13 tests; all 11 fix tests fail against main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The repository is public. Replace the AWS account ID, instance, Elastic
IP allocation, security group, Auto Scaling group and key pair names with
placeholders, and recommend digest-pinned rollback revisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--extra-index-url let pip take any locked package from the PyTorch
index (it served Jinja2 and MarkupSafe). Install torch/torchvision from
that index and everything else from PyPI only. The resulting packages are
identical to the deployed image (only the from-source neuroglancer
extension differs byte-wise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- decode_image refuses PNGs larger than 4096 px per side, checked from
  the header before any pixels are decoded.
- /ng_bbox_fn_save restores the shared center point when it rejects a
  bounding box, instead of leaving the invalid values in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Lauenburg
Lauenburg merged commit dc7d90d into main Oct 7, 2026
1 check passed
@Lauenburg
Lauenburg deleted the fix/security branch October 7, 2026 20:32
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