TL;DR: A negative assessment is a result. I ran eight standard attack vectors against a static single page application and documented every failure, then turned the list into a regression gate that runs on every build.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
A data platform delivered as a pre-rendered single page application on a static host with an edge runtime in front. All eight planned vectors were executed and recorded, including path traversal, server-side template injection, API abuse, storage exposure and authorisation bypass.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
Most published web application findings assume a server that renders something. That assumption is what makes them cheap: inject here, execute there, read the response. Remove the server-side renderer and the majority of these techniques have nothing to attach to.
A static build changes the shape of the problem rather than removing it. There is no runtime to inject into, which removes server-side template injection, deserialisation and most injection classes outright. Attack surface reduces to the edge configuration, the client bundle and whatever API the bundle talks to. That last part is where the real work is, and it is where a static site either holds or quietly becomes a thin client for an unprotected backend.
Recording the failures matters as much as recording the successes. A negative assessment is the only way to justify keeping an architecture that a later reviewer will otherwise question, and the recorded vector list becomes a regression suite: the same eight tests, rerun after every deployment, show whether a change opened a door.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| INFO | Path traversal not reachable | Static routing returns the same document for every path |
| INFO | Server-side template injection impossible | No server-side rendering in the request path |
| INFO | Administrative routes not served | Unpublished routes absent from the build output |
| INFO | Storage buckets not public | Media served through signed, expiring URLs |
| INFO | Authorisation enforced in the data layer | Direct object access tested against foreign identifiers |
| INFO | Edge normalised host and path | Host header poisoning and path confusion rejected |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Run the regression suite against your own static deployment
# target: your own static site on localhost
$ python3 -m http.server 8080 --directory dist
# 1. traversal and confusion
$ for p in '/../etc/passwd' '/%2e%2e/%2e%2e/etc/passwd' '/index.html?x=../../secret'; do
printf '%s -> %s\n' "$p" "$(curl -so /dev/null -w '%{http_code}' "http://localhost:8080$p")"
done
# 2. unpublished routes must not resolve to content
$ for p in /admin /api/admin /internal; do
printf '%s -> %s\n' "$p" "$(curl -so /dev/null -w '%{http_code}' "http://localhost:8080$p")"
done
Detection in your own estate
Compare the published route list with the build output
# what does the build actually contain
$ ls dist/assets | head
# and what does the edge serve for paths that are not in it
$ for p in /admin /api/admin /.env /config.json; do
printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://example.org$p)"
done
Check the API authorisation from the outside
# use an object you own, then swap only the identifier
$ curl -s -o /dev/null -w '%{http_code}\n' 'https://api.example.org/v1/items/own-id'
$ curl -s -o /dev/null -w '%{http_code}\n' 'https://api.example.org/v1/items/other-id'
Remediation
Keep the negative result as a regression gate in the pipeline
# run the static regression checks on every build
name: static-surface
on: [push, pull_request]
jobs:
surface:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: unpublished routes must not resolve
run: |
for p in /admin /api/admin /.env /config.json; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080$p")
test "$code" = "404" || { echo "leaked route: $p ($code)"; exit 1; }
done
Takeaways
- A negative assessment is a deliverable, not an absence of one.
- Static delivery removes injection classes, not authorisation.
- Record the vectors you proved fail, then rerun them forever.
- The bundle and the API are the whole attack surface once the server is gone.