Skip to content

Latest commit

 

History

History
62 lines (45 loc) · 15.2 KB

File metadata and controls

62 lines (45 loc) · 15.2 KB

Security

Reporting a vulnerability

Report privately through GitHub: open the Security tab and use Report a vulnerability. That opens a private advisory visible only to you and the maintainers. Do not open a public issue for a vulnerability.

A useful report says what an attacker can do, which files or endpoints are involved, and how you confirmed it. A proof of concept helps but is not required.

potocolom is maintained by one person. Expect acknowledgement within a few days rather than a few hours. There is no response-time commitment and no bug bounty.

Supported versions

No release is tagged yet. main is the only supported ref, and fixes land there.

Scope

Read the list below before reporting. Several open surfaces here are recorded decisions rather than oversights, and reports about them cost you time without producing a fix. docs/decisions.md carries the reasoning and the rejected alternatives for each.

Known and deliberate, so not vulnerabilities:

  • The fleet WebSocket (/api/v1/fleet) authenticates workers with a shared secret in FLEET_TOKEN_KEY, compared in constant time. An unset key refuses the handshake (HTTP 403 before accept) and refuses to start the API. scripts/preflight.sh writes deploy/compose/.env on first run so a fresh install has a secret. Origin check stays. Signed cloud tokens remain #225. Both WebSocket endpoints reject a browser Origin that is not allowlisted, so a page you merely visit cannot reach them; a process on the same network still can, which is what worker authentication is for.
  • AUTH_MODE=none is the shipped default and resolves every request to one implicit local admin, so it belongs on a trusted network. AUTH_MODE=accounts boots and authenticates, for REST and for the realtime socket. Turning it on is one way: make auth-enable records the change, and the install refuses to start in none mode afterwards.
  • Model discovery requires an authenticated principal. Install operations, including studio GPU status, GPU history, telemetry preview, benchmark operations, and benchmark session reads and writes, require the admin role.
  • Asset reads use opaque asset IDs. The API checks the asset owner or admin role, returns 404 for missing or unauthorized assets, and returns 400 for an unsafe download name. The retired storage-key GET route always returns 404. For local storage, worker input uses an opaque 32-byte capability that expires after 15 minutes and does not reveal the storage key.
  • Authenticated API responses are not cacheable. Responses under /api/v1/ include Cache-Control: no-store.
  • Thumbnail inspection proves structure, not decodability. _png_dimensions and _webp_dimensions in backend/app/storage.py walk the container and chunk headers and never decode. A VP8 chunk needs ten header bytes and a VP8L five, so a container carrying a header and no compressed data is accepted and a thumbnail row is created for a file no decoder will render. The result is a broken image in the gallery, chosen by the worker, and not active content. Self-hosted API responses carry X-Content-Type-Options: nosniff. Cloud presigned S3/MinIO GET URLs do not; they rely on stored Content-Type plus a CloudFront response-headers policy that sends nosniff. A decoder does not belong on the request path: decoding here was twice a denial of service, once unbounded and once bounded by a peer-supplied size. Tracked in #281.
  • Audit fails open, and covers admin work that changes something. An admin request with an unsafe method is recorded before it runs, in the admin role check itself rather than per route, so a route added later cannot forget. Admin reads are not recorded: the studio polls two of them every two seconds, and recording those would bury real administrator work under millions of rows any caller can drive for free. A read that reaches another user's data is a different thing, carries a target the role check cannot know, and will record itself when those routes exist. Today every admin route with an unsafe method sits behind BENCHMARK_API, which is off by default, so a default install writes no audit rows yet. When the record cannot reach PostgreSQL the action still proceeds: refusing privileged work every time the audit table is unreachable turns one incident into an outage. The loss is never silent. Each lost record is written as a structured JSON line on the potocolom.audit logger, a bounded 1000-event spool keeps it for the next successful insert, and the flush carries distinct high-severity audit.fallback and audit.overflow events so the seven-day summary shows the gap. A sustained outage past 1000 events drops the oldest and counts them into audit.overflow. Delivery is serialized and bounded to five seconds, so a slow audit cannot queue admin requests behind it. Records are kept 90 days. The actor and target are plain ids with no foreign key, so deleting an account erases neither what it did nor who did it.
  • Registration is invitation-only, and an invitation link is a bearer capability. Anyone holding the link can claim that one address at that one role, once, within 72 hours. That is the point: a self-hosted install is not required to run mail, so the administrator copies the link and hands it over by whatever means they trust. Only the SHA-256 is stored, so the link is displayed once; asking to see it again mints a new one and retires the old, because a link nobody could read may have leaked on the way to them. Treat the link like a password.
  • Promotion to administrator needs evidence, and demotion needs none. The caller must have authenticated in the last 30 minutes, and the target must either hold a verified address or be promoted under an explicit attestation, which is recorded against the account it promoted. On a no-mail install the attestation is the only evidence available, and it is deliberately a statement by a named administrator rather than a checkbox that leaves no trace. An administrator cannot change their own role at all, and the last administrator cannot be demoted, because an install with no administrator can only be recovered offline.
  • The realtime socket authenticates on the upgrade, and binds once. In AUTH_MODE=accounts the session cookie is resolved before the WebSocket is accepted: no cookie fails the handshake as HTTP 403, so no socket exists and nothing is admitted; a cookie that resolves to nothing closes 4401; a viewer, or any account that is not active, closes 4403, before a GPU slot is spent. The identity comes from the server, never from the browser's payload. The principal is then bound for the life of the connection rather than re-checked per frame, so revocation is explicit: revoking the account session, which is what logout, disable, deletion and a role change all do, closes the live socket. In AUTH_MODE=none the socket binds the implicit local administrator exactly as before, and the Origin allowlist remains the only gate, which is why that mode belongs on a trusted network.
  • A second factor is optional, and it gates sign-in only. When an account has enrolled and confirmed TOTP, a correct password or provider sign-in mints no session at all: it returns a challenge capability in a short-lived cookie, which authorizes nothing, cannot be quoted from another browser, expires in ten minutes and allows ten attempts counted before anything else. Enrolment must be confirmed by a code, so a mistyped setup cannot lock an operator out, and nothing is written until that code arrives: the pending enrolment is held by the browser setting it up, sealed under the root key ring and bound to the account. Replacing an authenticator therefore keeps the working one working until the new one answers, and abandoning the flow leaves the account exactly as it was. Writing first would have made enrolment the cheapest way to turn the second factor off: one request from a stolen session, no code, and no notice to the account holder. Recovery codes are one use and stored only as hashes. The TOTP secret is sealed with the root key ring and bound to the account it belongs to; a secret this installation can no longer read is a factor nobody can pass, which is the direction that fails safe.
  • An administrator is never sent a password reset by email. A credential recoverable from a mailbox is only as strong as that mailbox, so the administrator route back is make auth-recover at the machine, which prints a one-use link valid ten minutes. Everyone else gets an emailed link valid thirty minutes. POST /api/v1/auth/reset answers identically whether or not anyone holds the address, so it cannot be used to learn which accounts exist. Completing a reset revokes every session that account held, because whoever forced it may be the one holding a stolen session, and it returns the person to the login screen rather than handing back a session. <<<<<<< HEAD
  • One accounts process per installation, refused at startup. Without Redis there is no shared state between API processes: a realtime socket binds in whichever process accepted it, and dispatch refuses to cross, so two processes do not fail loudly, they disagree quietly. The second one takes a PostgreSQL advisory lock on a connection of its own, fails to get it, and refuses to start. =======
  • A share link is a bearer capability, and it is deliberately one. Anyone holding /shared#<token> sees that one picture, its prompt and its model until the owner revokes the share or its 1, 7 or 30 days run out. The token sits in the URL fragment, which browsers never send to a server, and is presented in a POST body, so no route accepts it in a path or a query where it would land in access logs, proxy traces and Referer headers. Only its SHA-256 is stored. One share per asset is active at a time: sharing again revokes the previous link in the same transaction, so revoking the link somebody can see cannot leave an older one alive. The answer carries no address, no account id and no storage key, and the picture behind it is addressed by a signature that lasts 60 seconds and names the share, so revoking a link also ends the addresses it already handed out.

origin/main

  • An account state is the one place that says what an account may do. Leaving active revokes every session, closes the realtime sockets those sessions bound, spends the outstanding reset and recovery links, and cancels the queued and running jobs. A suspended account may sign in and read its own work and settle its account, and may change nothing, hold no GPU slot, and speak to nobody: its share links stop resolving until it is restored, paused rather than revoked so that restoring the account restores what it shared. Disabled, deletion-pending and purging accounts cannot sign in through any door. Transitions are compare-and-set inside one transaction and idempotent, so a retry after a timeout is not a second event, and an administrator can change neither their own state nor the last active administrator's.
  • Cancelling work marks PostgreSQL first and tells the worker second. The row is the authority and the message is a courtesy: a worker that is gone, wedged, or running an older protocol changes nothing about whether a job is cancelled. Anything such a worker uploads afterwards is discarded rather than committed, and the GPU milliseconds it reports are still charged, because the hardware really did run for that long. cancelled is a job state and never an account state.
  • An export is everything about one account and no secret of any kind. It carries the account, its identities and its generations with their assets, and never a password hash, a session hash, a TOTP secret or a recovery code. A file that leaves the building takes whatever is in it wherever it goes: a password hash is an offline cracking target and a session hash is a live credential. It is paged out of PostgreSQL and streamed, so the size of a library cannot exhaust the process serving it, and because that stream outlives the request that authorised it, it rechecks between pages that the account is still active and stops if it is not.
  • Deleting an account stops it at once and destroys nothing for 30 days. The state becomes deletion_pending, every session and outstanding link is spent, the sockets close, and the queued and running jobs are cancelled. The account remembers the state it was in, one level deep, and a restore inside the window puts it back there rather than to active. After the window a sweep purges the objects, then the rows that named them, then the user row, and a purged user row no longer exists. Audit rows carry plain ids with no foreign key, so what an administrator did survives the account it was done to. The last administrator may delete their own account: an install with nobody in charge can be recovered offline, and an administrator held hostage by their own install cannot.
  • An administrator reads one account at a time, and the read is recorded. There is no global gallery and no cross-user search: the way in is a named account, and every privileged read writes an audit row carrying the account it reached, because the role check that guards the route cannot know which one that was. Administrators read and administer; nothing lets one change another account's work. Listing every account is recorded too, because it reaches every account at once. Opening more than 20 different accounts inside 30 minutes raises one high-severity event and puts that administrator on an anomalies panel, and refuses nothing: an administrator working through a queue of complaints looks exactly like a stolen administrator session, and only a person can tell the difference. Reading the whole audit is itself audited, with the ids of the events it took.
  • There is no rate limiting. Deferred by recorded decision, see docs/decisions.md.
  • Pull requests execute contributor code on a self-hosted runner. Accepted for a solo org and documented in docs/self-hosted-runner.md.

In scope, and worth reporting:

  • Anything that crosses a boundary the documentation claims holds. If a document says a check is enforced and it is not, that is a finding regardless of how small it looks.
  • Anything reachable by a client that is not on the operator's private network. A web page the operator merely visits counts: report any way one reaches an endpoint despite the Origin check.
  • Anything that lets one user reach another user's jobs, assets or sessions.
  • Anything that lets a worker corrupt or forge state the API treats as authoritative.
  • Anything in the shipped container images, compose stack or release artifacts.

Out of scope: findings in design-sketches/ or other untracked local scratch, issues that require an already-compromised host, and results from automated scanners submitted without a working path to impact.

Disclosure

Coordinated. Report privately, and we agree on a disclosure date once a fix exists or the issue is confirmed as won't-fix. Reporters are credited in the advisory unless they ask not to be.

Self-hosting

Deployment posture is part of the security model for the self-hosted profile. docs/self-hosting.md covers what to expose and what to keep on a trusted network. A misconfigured deployment is an ordinary issue, not an advisory.