TL;DR: The API documentation was public, and it was the best map you can ask for: every path, field name and validation rule, including the administrative routes no interface links to. I show how to disable it outside development.
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 software product built with an automated application framework, with a separate client-side frontend, third-party services for payments, artificial intelligence and mail, and object storage for media. Assessment covered the public schema and documentation endpoints, header policy, script integrity, mail authentication and the trust chain.
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:
Frameworks that generate a client from a type definition will happily generate documentation from the same definition. This is a feature: the documentation cannot drift from the implementation, because both come from one source. The consequence is that publishing the documentation is a single environment decision, and the default in several popular frameworks is to publish it in every environment including production.
An interactive documentation page is more than a convenience. It renders the real schema, with field names, types, required flags and validation rules, which is the information an attacker needs to build correct requests rather than guessing parameter names. The machine-readable schema behind it is better still, because it enumerates every path including administrative and internal ones that no user interface ever links to.
The deeper pattern is default configuration. Every control in this engagement was a framework default rather than a decision: documentation on, inline scripts allowed in the policy, analytics loaded from a self-hosted instance on a separate server, media served from a storage endpoint with a wildcard origin policy. None of these are errors in the sense that somebody got them wrong. They are the shape of the framework, and the review has to look at defaults precisely because nobody chose them.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| MEDIUM | Interactive documentation publicly served | Full schema with field names and validation rules |
| MEDIUM | Machine-readable schema lists internal and administrative paths | Complete route inventory including paths no interface links to |
| MEDIUM | Inline scripts permitted in the content security policy | No restriction on inline execution |
| MEDIUM | Third-party script loaded without integrity and from a separate host | Compromise of that host means full control of the page |
| LOW | Media endpoint serves with a wildcard origin policy | Any origin may read stored files |
| LOW | Transport policy missing the preload and subdomain scope | Hardening present but incomplete |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Check the well-known documentation paths on your own service
# target: your own lab service on localhost
$ for p in /docs /redoc /openapi.json /api-docs /swagger /swagger.json; do
printf '%-16s %s\n' "$p" "$(curl -so /dev/null -w '%{http_code}' "http://localhost:8000$p")"
done
# if the schema is public, read the route inventory from it
$ curl -s http://localhost:8000/openapi.json | python3 -c '
import json,sys
spec = json.load(sys.stdin)
for path, ops in spec.get("paths", {}).items():
print(path, ",".join(k.upper() for k in ops))' | head -40
Detection in your own estate
Look for inline execution allowances in your own policy
$ curl -sI https://example.org/ | grep -i 'content-security-policy' | tr ';' '\n' \
| grep -E 'script-src|unsafe-inline|unsafe-eval|\*'
List every third-party script origin your page loads
$ curl -s https://example.org/ | grep -oE '<script[^>]*src="https?://[^"]*"[^>]*>' \
| grep -oE 'src="https?://[^/]+' | sort -u
# and how many of them carry integrity
$ curl -s https://example.org/ | grep -oE '<script[^>]*src="https?://[^"]*"[^>]*>' \
| grep -c integrity
Remediation
Disable documentation outside development
# FastAPI: the documentation is opt-in, so make production opt-out
import os
ENV = os.environ.get("APP_ENV", "production")
app = FastAPI(
title="Internal service",
# both default to True; leaving them unset publishes the schema
docs=None if ENV == "production" else "/docs",
redoc=None if ENV == "production" else "/redoc",
openapi_url=None if ENV == "production" else "/openapi.json",
)
# and assert it in a test, so a future default change cannot reintroduce it
def test_no_schema_in_production(client):
app.openapi_url = None
assert client.get("/openapi.json").status_code == 404
assert client.get("/docs").status_code == 404
Once an attacker gains access, they can perform the following actions:
Takeaways
- A generated schema is an attack surface map that costs one request.
- Documentation is opt-in in the framework and opt-out in your environment. Set it explicitly.
- Assert the absence in a test; a default change will not announce itself.
- Audit the third-party script origins. Owning your own server is not the same as controlling it.