This is a portfolio project, not an audited product. What follows is an honest account of the controls that exist, the reasoning behind them, and — in the last section — what is deliberately missing. The gaps are listed because a security page that only lists strengths is worse than no security page.
Assumed adversary: an authenticated user of the API, or an unauthenticated party who can reach it over the network. Assets they want are other users' uploads and outputs, the ability to run code on a worker, and the ability to exhaust the service.
Explicitly out of scope: a compromised host, a malicious operator, and side channels in the model itself.
Passwords. bcrypt with a per-hash salt at cost 12 — 216 ms per hash measured on the benchmark machine's CPU (the Xeon described in benchmarks.md), which is high enough to matter and low enough for a login. The figure is hardware-dependent; cost, not milliseconds, is what is pinned in the code.
Two bcrypt details are handled explicitly because both are easy to get wrong:
- bcrypt truncates at 72 bytes. A 200-character passphrase is only as strong as its first 72 bytes, and bcrypt 5.x raises rather than truncating. Passwords are pre-hashed with SHA-256 and base64-encoded to a fixed 44-byte string first, so length is bounded, no entropy is discarded, and there is no NUL-byte truncation issue.
- passlib is not used. passlib 1.7.x reads
bcrypt.__about__, removed in bcrypt 4.1, and the compatibility shim is more code than calling bcrypt directly.
Verification is constant-time via bcrypt.checkpw and returns False rather than raising
on a malformed stored hash.
Tokens. HS256 JWTs with sub, exp, iss and a typ claim. Verification:
- An explicit single-algorithm allow-list. This is the defence against the classic
alg: noneand RS256→HS256 confusion attacks. Not passingalgorithmstojwt.decodeis the bug that makes those attacks work. exp,subandissare all required and validated. A token without an expiry is rejected rather than treated as non-expiring.typmust beaccess, so a token minted for another purpose cannot be replayed here.is_activeis re-checked from the database on every request, so deactivating an account takes effect immediately rather than at token expiry.
Login is not an account-existence oracle. Unknown email and wrong password both return
401 invalid_credentials with the same body.
The development signing key is refused in production. Settings raises at
construction when CUTOUTML_ENVIRONMENT=prod and jwt_secret is still the default. A
default secret is not a weak secret — it is a published one, so anyone with a clone can
mint a token for any user id. The check lives on Settings rather than in the API so the
worker and the CLI refuse the same configuration, and it is a crash rather than a warning
because a warning in a log nobody reads is how this reaches production.
Every query is scoped by owner_id at the database level. There is no "fetch then check"
step that can be forgotten, because the WHERE clause is the check.
A resource owned by someone else returns 404, not 403. A 403 confirms that the id
exists, which is an enumeration oracle. The two cases are deliberately indistinguishable.
The storage key layout provides a second, independent scope ({kind}/{user_id}/…), but it
is defence in depth and never the primary check.
Uploads are the largest attack surface here, so validation is ordered deliberately — cheapest and most decisive checks first — and nothing the client says is trusted:
- Reject empty.
- Sniff the content type from magic bytes. The declared
Content-Typeand the filename extension are then cross-checked against the sniffed type, and a mismatch is rejected (content_type_mismatch). Neither client-supplied value is ever used as the answer. Only an allow-list of image and video MIME types is accepted. - Size against the real byte count. The
Content-Lengthcheck in middleware is a cheap first gate, not the real check:Content-Lengthis a claim. Images and videos have separate limits (32 MiB / 256 MiB by default). - Pixel count from the header, before decoding. A 20 KB PNG can declare 50,000 ×
50,000 pixels. Decoding it first is a decompression bomb; the limit
(
CUTOUTML_MAX_IMAGE_PIXELS, 64 MP) is checked against declared dimensions while the file is still 20 KB. - Then decode, with a distinct
corrupt_imageerror for a valid signature with undecodable content.
Videos are probed with ffprobe and rejected if they exceed CUTOUTML_MAX_VIDEO_FRAMES
(18,000 — ten minutes at 30 fps).
Covered in full in ADR-006. The security-relevant parts:
- Storage keys are server-generated and random (128 bits). The client's filename never appears in a path, so path traversal is not mitigated — it is structurally impossible, because the untrusted input is never used to construct the path. The filename is kept in the database for display only.
- Caller-supplied prefixes are rejected, not repaired.
sanitize_prefix()raises on any..segment. Normalising a traversal attempt away destroys the signal that someone tried. - The local backend contains escapes anyway. Every key is resolved — symlinks first — and checked against the storage root, so a key that somehow bypassed generation still cannot write outside it.
- Writes are atomic (
os.replacefrom a temp file in the destination directory). Without that, a crash mid-write leaves a truncated object that looks valid, and an API polling for a result a worker is writing can read a partial file. - Presigned URLs expire (900 s by default) and are scoped to a single key and method.
A token bucket, not a fixed window. A fixed window lets a client spend its whole quota in the last millisecond of one window and again in the first millisecond of the next, producing twice the intended peak.
The Redis implementation is a Lua script, so the read-modify-write is atomic. Doing
GET, compute, SET from Python is a race in which two concurrent requests read the same
token count and both succeed — a limit of 1 admits 2. Under real concurrency that is not an
edge case. Idle buckets expire, so the key space does not grow without bound.
When Redis is unavailable the limiter degrades to a per-process in-memory bucket and logs it. This is a deliberate availability-over-strictness choice, and it has a consequence worth stating plainly: with N API replicas, the effective global limit during a Redis outage becomes N × the configured value. That is documented rather than hidden. It is the right trade for this system and the wrong one for a system where the limit is a billing control.
Other resource controls: body-size limits before routing; a hard frame ceiling per video
job; Celery hard and soft time limits (3600 s / 3480 s) so a pathological job is killed with
two minutes of warning to record why; worker_prefetch_multiplier=1 so one worker cannot
hoard the queue.
- SQL. SQLAlchemy Core/ORM with bound parameters throughout. No string-built SQL anywhere.
- Subprocess. ffmpeg and ffprobe are invoked as argument lists, never with
shell=True, so a filename cannot become a shell command. Binaries are resolved withshutil.whichagainst the configured name. - Paths. Both wrappers are context managers and kill the child process on exit, including on exception. An orphaned ffmpeg holding a pipe is the classic way a video worker leaks until the OOM killer arrives.
- Deserialisation. Celery is configured for JSON only; pickle is not enabled. Model
checkpoints are loaded with
torch.load(..., weights_only=True), becausetorch.loadwithout it is arbitrary code execution from a.ptfile — which matters as soon as anyone downloads a checkpoint from the internet.
- CORS is an explicit origin allow-list, not
*. The API uses bearer tokens that a browser stores, and a wildcard origin with credentials is what turns XSS on any site into account takeover here. Allowed methods, headers and exposed headers are all enumerated. - CSRF is not applicable to the token flow: authentication is an
Authorizationheader, not a cookie, so a cross-site form post carries no credentials. This stops being true the moment anyone adds cookie auth. - Downloads carry the sniffed content type and are served from storage rather than from a path derived from user input.
- The Next.js frontend proxies
/api/*to the API by default, so in normal operation the browser makes same-origin requests and CORS is not exercised at all.
.envis gitignored;.env.examplecontains names and non-secret defaults only.- No credential is committed. The compose stack's Postgres and MinIO credentials are development values for throwaway containers and are visible on purpose.
- Production S3 credentials are expected to come from the ambient credential chain
(instance role, IRSA), which is why
CUTOUTML_S3_ACCESS_KEY_IDdefaults to unset. - Errors returned to clients never include a stack trace or a connection string;
500responses carry a generic message and arequest_idthat correlates to the server log.
Listed because these are the honest gaps, and because knowing which is which is the difference between a limitation and a surprise:
- No audit log. Who deleted what, and when, is not recorded beyond application logs.
- No API keys, no refresh tokens, no MFA, no token revocation list. A leaked token is valid until it expires (one hour). Deactivating the user takes effect immediately, which is the only revocation mechanism.
- No malware scanning of uploads. Bytes are treated as pixels, never executed, but they are stored and served back.
/metricsis unauthenticated. A scraper is not a user. It must be network-restricted; it leaks request volumes, job counts and model names.- No per-endpoint rate limits. One bucket for everything, so cheap
GETs consume the same budget as expensive job submissions. - No tenant isolation beyond the database scope and the key prefix. All tenants share one bucket, one database and one worker fleet. A worker processes jobs from multiple users in one process, so a hypothetical memory-disclosure bug in a decoder crosses tenant boundaries.
- No signed URLs on the local backend. Presigning there has no cryptographic meaning, so development and production differ in whether a download can bypass the API.
- No dependency scanning or SBOM in CI.
- No account lockout or login throttling beyond the global rate limit, so password guessing is bounded at 120 attempts per minute per IP-derived bucket for unauthenticated requests.
This is a personal project with no security contact and no disclosure process. If you find something, open an issue.