Skip to content

Security

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

Security

The API holds a fleet's complete inventory, so everything past the health probes is authenticated. This page describes the model as implemented.

Credentials

Five ways to authenticate. They are tried in this order, and the first that resolves a principal wins.

Credential Header Scopes
Internal secret X-Internal-Secret All. The dashboard-BFF-to-API hop.
Easy Auth principal X-MS-CLIENT-PRINCIPAL-ID All. Ignored unless TRUST_EASYAUTH_PRINCIPAL_HEADER is on.
API key X-API-Key Only what the key was granted.
OIDC bearer token Authorization: Bearer <jwt> Mapped from the IdP's roles.
Shared passphrase X-Client-Passphrase or X-API-PASSPHRASE All. What the agents send.

Legacy credentials — the passphrase, the internal secret, a trusted Easy Auth principal — carry every scope for backward compatibility. Only API keys and OIDC tokens are scope-limited. The intended direction is to move callers onto scoped keys and then retire the shared passphrase.

Comparison of the passphrase and internal secret uses hmac.compare_digest. The passphrase loop compares every configured candidate without an early break, so the time taken does not reveal which entry matched.

Credential fall-through

The deployed Windows agent sends both X-API-Key and X-Client-Passphrase when both are configured. So when an API key or bearer token fails and another credential accompanies it, the request falls through to that credential rather than being rejected. A failed API key is only a hard 401 when it was the sole credential presented. Hard-failing on the key alone caused a total ingestion outage once; this is the fix.

Easy Auth header

X-MS-CLIENT-PRINCIPAL-ID is only evidence of anything when a platform that strips inbound X-MS-* headers and injects validated ones sits in front of the app. Without that guarantee anyone can type the header, which would make this branch a full-scope bypass. It is therefore ignored unless TRUST_EASYAUTH_PRINCIPAL_HEADER is explicitly enabled, and presenting it without the opt-in is logged.

Scopes

Three least-privilege buckets, enforced by one gate after the principal is resolved:

Scope Grants
read GET, HEAD, OPTIONS
ingest POST
admin PUT, PATCH, DELETE, and anything whose path contains /admin

The scope is derived from the request, not declared per route. Three consequences:

  • POST /api/v1/admin/usage-history/reset-baseline needs admin, not ingest, because of the path.
  • GET /api/v1/debug/database does not contain /admin, so a read credential reaches it. Bear that in mind when issuing read keys.
  • PUT /api/v1/inventory-state needs admin by method, so the asset inventory integration that writes it needs an admin-scoped key or token.

Anonymous routes are an explicit allowlist, enforced by a test: the three health probes and GET /api/v1/auth/config. The last describes how to authenticate — the accepted headers and, when OIDC is on, the issuer and audience — and carries nothing that is not already in a token's claims or a 401 body.

A scope rejection is recorded as an ingest failure with reason insufficient_scope, so a misconfigured integration is visible rather than silent.

API keys

Format rm_<id>_<secret>: a 12-hex-character public id and a 48-hex-character secret. Only sha256(secret) is stored, compared with hmac.compare_digest. The full key is returned exactly once at creation and cannot be recovered — a lost key is revoked and replaced.

Revocation is a soft delete (active = false) so the audit trail survives. Create and revoke both append to api_key_audit with the acting principal. last_used is stamped best-effort on each successful authentication; a failure there is logged and never breaks authentication.

The first keys are minted with the shared passphrase, which avoids a bootstrap problem.

OIDC bearer tokens

Secretless federated SSO against any OpenID Connect provider. ReportMate stores nothing for this path: identity is proven by the provider's signature over a short-lived token, so there is nothing to rotate and nothing to leak.

The validation order matters:

  1. The unverified iss is read first and checked against OIDC_ISSUERS. An untrusted issuer is rejected before any cryptography runs, and in a multi-issuer deployment this selects the right JWKS.
  2. The signing key is resolved from the issuer's /.well-known/openid-configuration (or a pinned OIDC_JWKS_URI) by the token's kid, cached per issuer.
  3. Signature, audience, issuer, and expiry are verified. exp is required.
  4. Roles, groups, and scp / scope claims are mapped to ReportMate scopes and unioned.

Only asymmetric algorithms are accepted — RS256, RS384, RS512, ES256, ES384 by default. none and HS* are excluded on purpose, to prevent an algorithm-confusion attack where a token is signed with the public key.

A valid token that maps to no scope is rejected, and the rejection is logged with the principal. Set OIDC_DEFAULT_SCOPES only if you deliberately want any authenticated token in the tenant to have a floor of access.

A client discovers the audience to request a token for from GET /api/v1/auth/config, so nothing about the deployment needs to be stored on the client.

Validation failures return None rather than raising, which keeps the reason out of the response — details are logged server-side only — and lets an accompanying credential be considered.

Rate limiting

Two fixed-window buckets, both counted on every request:

Bucket Key Default
Device X-Device-Serial header 120/min
Address First hop of X-Forwarded-For, else the connection address 3000/min

One address is not one device. An entire site leaves through the same public egress, so a single per-address bucket puts a whole fleet in one allowance. The device bucket fixes that; the address bucket remains as a flood shield, sized for a fleet rather than a machine.

The serial header is spoofable and is deliberately still counted against the address bucket. This limiter exists to survive a fleet stampede, not an adversary — the endpoint requires authentication regardless. Clients that do not send the header are governed by the address bucket alone.

A caller presenting the valid internal secret bypasses both, because the dashboard funnels every user through a handful of egress addresses and would otherwise starve real users.

Counting is in process memory per replica, so the fleet-wide ceiling scales with the replica count.

Error masking

Any response of 500 or above has its detail replaced. Server-error detail can carry exception text, SQL, connection strings, or stack fragments, so the caller gets a generic label plus a reference id that matches the X-Request-ID header and the server log line. Client errors below 500 are intended for the caller and are preserved verbatim.

The unhandled-exception handler logs a full traceback server-side and returns the same masked shape.

SQL

Every query is parameterized. Table and column names that appear in f-strings are validated against fixed allowlists first — the module tables, the valid module names, the export column list. load_sql() rejects path traversal and resolves the path against the sql/ directory before reading.

Data handling notes

  • Log injection. The proxy-supplied X-Updated-By on PUT /api/v1/settings has CR and LF stripped and is truncated to 255 characters before it is stored or logged.
  • Rate-limit key growth. The X-Device-Serial value is unauthenticated at the point it becomes a bucket key, so it is restricted to alphanumerics, -, and _, and truncated to 64 characters.
  • Client address. Recorded from the first hop of X-Forwarded-For when present. Recording the ingress address is worse than recording nothing, because it reads as a real device.
  • Zip bombs. A compressed request body is inflated with a hard ceiling enforced during decompression, so a small crafted body cannot exhaust the container before a size check runs.
  • NUL code points. Stripped from ingest payloads because PostgreSQL jsonb cannot store them, and the occurrence is recorded so the client defect stays visible.

Known constraints

Called out because they shape how you should deploy this:

  • The shared passphrase is a full-access credential held by every device in the fleet. Anything holding it can read the whole inventory and reach every admin mutation. Treat scoped API keys as the target state.
  • There is no per-user identity in this tier. Role enforcement for settings writes happens upstream in the dashboard's proxy layer; the API only checks that the caller is the internal service. OIDC narrows this by carrying an IdP principal, but the scope model is still coarse.
  • /api/v1/metrics requires only read, and so does /api/v1/debug/database.
  • DISABLE_AUTH exists. The startup guard makes it unusable in a production-like configuration, but it is still a switch that turns off all authentication.

Reporting a vulnerability

Do not open a public issue. Contact the maintainers through the channel listed on reportmate.app.

Clone this wiki locally