omnigate

Security hardening

A checklist for operators: what to expose, where each credential lives, and what to look after once the gateway is running. It describes the code as committed and the Compose deployment in this repository; adapt the same rules to any other way of running it.

The security model, the known limits and the advisories for past vulnerabilities are in SECURITY.md. Report a vulnerability the way that file says, not in a public issue.

Nothing here has been proven under attack. The measures below are covered by unit tests, by tools/malformed_test.py and by the container test, all against mock providers; the project has not had an external penetration test and has never run under production load.

The short version

  1. Publish only the proxy's ports. Never publish 4000 (control plane) or 8080 (data plane) themselves.
  2. Use three long random secrets: the internal token, the admin token (16 characters at least, or the control plane will not start) and gateway keys. Keep them in the file of the container that needs them and nowhere else.
  3. Set OMNIGATE_ADMIN_ALLOW to the networks your operators come from.
  4. Leave media.webhooks.allow_private off outside local development.
  5. Give every gateway key a model list, a rate limit and a budget.
  6. Size the data plane's limits to the memory it may use: the per-key concurrency cap and the two memory budgets. compose.yaml sets the budgets to fit its 1 GiB container limit; the binary's own defaults admit 2 GiB and are meant for a host without a limit.
  7. Price every media target, and close the options a client could use to make a job cost more than its price says (default_seconds, allowed_input, price_multipliers).
  8. Back up the control plane's /data as one set, and protect the backup like the live data.
  9. Treat the control plane's log as a secret if you let it generate the admin token; better, set one.
  10. Update when the changelog has a "Security" entry, and rotate what the advisory tells you to.

What to expose

Thing Expose to How
The gateway (https://$OMNIGATE_API_HOST) Your applications, or the internet Through the TLS proxy, ports 80 and 443.
The dashboard and admin API (https://$OMNIGATE_ADMIN_HOST) Operators only Through the TLS proxy, narrowed with OMNIGATE_ADMIN_ALLOW, or not published at all outside a VPN.
The control plane's port (4000) Nobody outside the private network It is plain HTTP and serves the internal API. compose.yaml does not publish it.
The data plane's port (8080) Only the proxy Plain HTTP. compose.yaml does not publish it.
/internal/* Only data planes The proxy answers 404 for it on the dashboard host; the control plane also refuses every percent-encoded spelling of a path, so /%69nternal/... does not get through either.

Things that are reachable without a credential, by design:

Both processes refuse what they do not recognise rather than passing it on. The control plane answers an unknown first path segment with 404 before any handler runs, so a route added later stays closed until it is deliberately opened, and it checks credentials before it reads a body: the only body it reads from a caller without credentials is the sign-in form, up to 4,096 bytes. The data plane accepts one spelling per path and refuses malformed or ambiguous request heads (API). Do not put a proxy in front that rewrites or normalises paths, and make sure it hands request bodies on with a Content-Length: the data plane answers 411 to a chunked request body.

The limits on the public port (8 MiB bodies, 16 KiB heads, timeouts, a connection cap, a cap on the requests one key has in flight, and budgets for the memory that request bodies and their parsing may take) bound what one client or one key can hold. Which of them you should size is under Limits to size. They are not a defence against a volumetric attack; that belongs to the network in front.

The three credentials

Gateway key Internal token Admin token
Held by Applications The two processes Operators
Opens Text endpoints, the jobs API and file downloads, within the key's model list, rate limit, budget, open-job limit and jobs-per-day limit /internal/* on the control plane: the config snapshot (key hashes, upstream addresses), usage intake, and the jobs API as any gateway key The dashboard, /admin/* (which creates and revokes gateway keys) and /metrics
Lives in (Compose) The application's own secret store. The gateway stores only its SHA-256: in config/omnigate.json for a file key, in /data/keys.json for a managed key .env.control and .env.dataplane, the same value .env.control only
Never put it in The config file in clear text ("key") outside local trials .env, the config file, an application .env.dataplane, .env, an application
Strength Generated ones are 32 random bytes; your own should be as long Long and random; neither process starts without it At least 16 characters or the control plane refuses to start; make it 32 random bytes
Guessing An unknown key is a 401; refusals are counted per reason, with a cap on distinct series Compared in constant time Compared in constant time. 5 failed sign-ins, or 20 wrong bearer tokens, per address per minute, then that address is locked out for the rest of the minute

None of them works in another's place.

The count of wrong bearer tokens covers every place the admin token is accepted as one: /admin/*, /metrics, and since commit d79406b also the dashboard page (/) and /login, which used to compare the token on their own. Once an address is locked out, its token is not compared at all, so the answer says nothing about whether it was right: /admin/*, /metrics, / and GET /login answer 429 too_many_attempts (GET /login showed the sign-in form with 200 until the close-out commit, the one after 55a294d). The two counts are separate: wrong bearer tokens do not lock the sign-in form, and failed sign-ins do not lock bearer access.

One way to make a secret:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Rotating them

Admin token. Change OMNIGATE_ADMIN_TOKEN in .env.control and re-create the control container:

docker compose up -d control

Every dashboard session ends (sessions are signed with a key derived from the token), and scripts and Prometheus need the new value. Gateway traffic is not interrupted: the data plane serves from its last config while the control plane restarts.

Internal token. Change OMNIGATE_INTERNAL_TOKEN in both .env.control and .env.dataplane and re-create both containers together:

docker compose up -d control dataplane

While the two values differ, the data plane logs config fetch returned 401, keeps serving text from its last config and spools usage. Signed file URLs handed out before the change stop working, unless OMNIGATE_SIGNING_SECRET is set separately (then they depend on that instead).

Gateway keys.

Provider keys, bucket credentials, webhook secrets. Rotate them at the provider, change the variable in the file of the container that reads it (text provider keys in .env.dataplane, everything else in .env.control), and re-create that container. A managed key's own webhook secret is changed with PATCH /admin/keys/{name}.

What each container holds

compose.yaml gives each container only its own environment file. The container test checks that the data-plane container has the internal token and no admin token.

Container Faces Environment file Secrets it holds State it holds
proxy (Caddy) The internet .env through Compose: host names and the allow-list TLS private keys, in the caddy-data volume Certificates
dataplane The internet, through the proxy .env.dataplane The internal token; text provider keys /data: the usage spool and a copy of the last config (key hashes and routes, no secrets)
control The private network; operators through the proxy .env.control The internal token, the admin token, media provider keys, webhook secrets, bucket credentials /data: the ledger, jobs, managed keys, the audit log, stored outputs

Keep it that way when you add variables: a new data-plane setting goes in .env.dataplane, and nothing the data plane does not read belongs there. If you still have a single .env with secrets from an earlier commit, split it (Deployment).

What a compromise of each would mean, so you know what to rotate:

Limit who can reach the admin host

By default anyone who can reach the dashboard's host name can try the admin token; the token and the lockout are then the only gates. OMNIGATE_ADMIN_ALLOW adds one in front of them. In .env:

OMNIGATE_ADMIN_ALLOW=203.0.113.0/24 2001:db8::/32

then docker compose up -d proxy. It is a space-separated list of CIDR ranges. The proxy answers 403 to every request for the dashboard host from outside them, before the control plane sees it; the gateway host is not affected. (The ranges above are documentation addresses; use your own.)

Things to get right:

The lockouts count per client address. compose.yaml sets OMNIGATE_TRUST_FORWARDED=1 on the control plane so that the address is the one the proxy reports in X-Forwarded-For rather than the proxy's own; otherwise one person guessing would lock every operator out. Set that variable only where a proxy you control is the only way to reach the control plane and writes the header itself. If you publish the control plane's port directly, remove it: a client could otherwise choose the address it is counted under and never be locked out.

"Writes the header itself" means: from the address of the connection, in place of whatever the client sent under that name. The included Caddyfile now says so explicitly for both hosts (header_up X-Forwarded-For {remote_host}), and the container test checks that a client which claims a new address with every failed sign-in is still locked out. Two things to get right in a setup of your own:

The header's value is also written to the audit log (forwarded_for), next to the address of the connection, so that a line can be traced when every connection comes from the proxy.

If you do not use the Compose proxy, the equivalent is a firewall rule or your proxy's own access control in front of the control plane's port.

TLS

Gateway keys

A gateway key in the wrong hands spends your provider budget. Bound each one:

Limit Field Effect
Which models models 403 for any other route, text or media.
How fast rpm 429 beyond it. Per data-plane process for text; job creation is counted again in the control plane, with the same kind of token bucket. The limit is charged before the body is read, so a request that is then refused for its JSON or its model has used one.
How much budget_usd with budget_period 402 once spent. Enforced after the fact: the call that crosses the line completes.
How many requests at once max_concurrent (default: the data plane's OMNIGATE_MAX_CONCURRENT_PER_KEY, 512) 429 concurrency_limit_exceeded beyond it, per data-plane process, before the body is read.
How many media jobs at once max_open_jobs (default 100, from media.max_open_jobs) 429 too_many_open_jobs beyond it.
How many media jobs per day max_jobs_per_day (default 10,000, from media.max_jobs_per_day) 429 too_many_jobs_today beyond it, until the UTC day turns. It bounds what the open-jobs limit does not: jobs that end at once (a refused input, a provider that is down) and still cost a log line, a ledger event and a webhook call each.

One limit applies to every key alike and has no per-key setting: GET /v1/jobs is limited to the key's rpm, or to 600 a minute for a key without one. See Limits to size.

Limits to size

A gateway key is a credential you handed to someone else. The data plane checks the limits below before it reads a request body, so that a valid key cannot decide how much the process holds. They are environment variables of the data plane (.env.dataplane), with defaults that are generous on purpose. Decide each one for your deployment.

Setting Default What it bounds Over it
OMNIGATE_MAX_CONCURRENT_PER_KEY 512 Requests one key has in flight. An upstream answer still being read after its client left counts until it is finished, so opening streams and hanging up is no way around it. 429 concurrency_limit_exceeded
OMNIGATE_REQUEST_MEMORY_MB 1024 Request bodies in flight, by declared length, each counted twice. One key may hold a quarter. 429 request_memory_exceeded for the key's share, 503 overloaded for the whole
OMNIGATE_PARSE_MEMORY_MB 1024 Memory reserved for parsing JSON into trees (translation, the response cache, provider quirks). One tree may take at most 96 MiB; one key may reserve half. 400 request_too_complex for one tree, 429 request_memory_exceeded, 503 overloaded
OMNIGATE_MAX_CONNECTIONS 4096 Client connections served at once. An immediate 503 overloaded
OMNIGATE_CLIENT_STALL_TIMEOUT_MS 30000 How long a client may stay connected without taking any of a response. The client is cut off; the call is still billed.
OMNIGATE_ABORT_DRAIN_MS, OMNIGATE_ABORT_DRAIN_MAX_MB 120000, 64 How long, and how much, an upstream answer is read on after its client left, to bill the provider's real figures. At most 256 answers are read on at once. An estimate is billed.

None of this has been sized against real traffic by this project. The measurements that exist are in Operations.

Media prices

A media job is billed from the price you configure, not from what the provider charges. Where the two can differ, a key holder can run up provider cost that the key's budget never sees. Close these gaps in the config file (Configuration):

Two more settings decide whether each call is counted once:

Outbound calls: webhooks and provider outputs

The control plane makes HTTP requests to URLs that somebody else chose: a client's webhook_url, and the output URLs in a provider's answer. Either could be aimed at something inside your network (a metadata service, an admin panel).

Where a call goes is one half. The other is what the far side can make the control plane do with its answer, since a webhook receiver is run by whoever holds a gateway key:

Stored outputs

Files in local storage are bytes a provider produced. They are served as downloads from a sandboxed context, never as pages on the gateway's origin: Content-Disposition: attachment, X-Content-Type-Options: nosniff, a sandboxing Content-Security-Policy, and a content type that is kept only for a short list of media types.

The gateway sends these headers itself (the data plane passes on what the control plane set), so they do not depend on a proxy. Make sure a proxy of your own does not strip or override response headers on /v1/files/*.

Serve the gateway on a host name of its own, not on one that also hosts an application with cookies.

With bucket storage the files never pass through the gateway on the way out: clients get presigned URLs. Keep the bucket private and give the gateway's credentials no more than put, get and (for retention) delete on its prefix.

Privacy mode

A key can be set to keep nothing of its requests in the gateway, to have addresses, numbers and secrets replaced by placeholders before a provider sees them, to drop the fields that name an end user, and to have its media outputs encrypted to a key only its owner holds: Privacy mode. Three things about it belong on this page:

Backups of /data

The control plane's /data volume is the installation: the ledger (segments, both snapshots and the per-day id files ledger.ids.YYYY-MM-DD), jobs.jsonl and its directory of large inputs (jobs.jsonl.inputs/), keys.json, the audit log and locally stored outputs. How to take and restore a copy is in Deployment. From a security point of view:

Log hygiene

Staying current, and upgrade notes

The only tagged version is the pre-release 0.1.0-rc.1; there is no stable release, so "current" means a recent commit of main.

Coming from 1cc721f or earlier: the commit after it fixes what a third review found (a provider made unreachable by one failed write, field names a lax provider reads differently, free provider calls through cancel, unbounded downloads). Update both processes; nothing needs rotating. What changes is in Upgrading.

Coming from a commit before e4e30e4 or d79406b (November 2026), in short (the full list is in Upgrading):

Coming from a commit before 3a0b147, in short (the steps are in Deployment):

Checking a deployment

From outside, with the names of your own deployment:

curl -s -o /dev/null -w "%{http_code}\n" https://$OMNIGATE_ADMIN_HOST/internal/config

must print 404, and

curl -s -o /dev/null -w "%{http_code}\n" https://$OMNIGATE_ADMIN_HOST/admin/keys

must print 401 (or 403 from outside the allow-list). Port 4000 and port 8080 must not answer from outside at all.

The repository's own checks:

What remains open is listed, without euphemism, under Known limits in SECURITY.md.

This page on GitHub