Security
Session
The session uses a sealed cookie named pluto-github-session.
- It is
httpOnlyandSameSite=Lax. - It is
Securewhen the app is not in development. - It lasts 7 days from the sign-in.
- The password is
NUXT_PLUTO_GITHUB_SESSION_PASSWORD. It needs 32 characters or more.
In production, a missing or short password makes the sign-in routes answer 500 and logs one error. In development, the CMS uses a random password for the process, so a restart signs everyone out.
The sign-in flow uses a second sealed cookie, pluto-github-oauth. It holds the state, the PKCE verifier, and the redirect target for 10 minutes.
The cookie is sealed, not stored on the server. A logout does not revoke a copy of the cookie. The token expiry and the permission check limit that.
Refresh and re-check
- Refresh. When the user token expires in less than 5 minutes, the session refreshes it with the refresh token. If GitHub refuses the refresh token, the session clears and the answer is 401. If GitHub does not answer, the answer is 503 and the session stays.
- Permission check. When the last check is older than 10 minutes, the session reads the repository again with the user token. If push access is gone, or GitHub rejects the token, the session clears and the answer is 401.
CSRF
SameSite=Lax is the first guard. A server middleware of core is the second. It runs for every layer, with or without the CMS.
For a request that is not GET, HEAD, or OPTIONS to /api/_pluto/**, the Origin header (or Referer when Origin is absent) must have the same host and port as the Host header. A mismatch, a bad value such as Origin: null, or no header gives 403.
The check uses the Host header and ignores x-forwarded-host. A proxy in front of the app must pass the public Host header.
GET routes never change state. The Public API (/api/pluto/v1/**) is read-only and is not under this prefix.
Rate limits
The login and callback routes allow 20 requests each minute for each IP. After that they answer 429. The IP is the socket address, or x-forwarded-for when a proxy sets it. The counter is in memory, one for each server instance. It is a brake, not a gate.
Response headers
Every auth response has Cache-Control: no-store.
OAuth hardening
The login route uses a random state and a PKCE verifier. The callback checks state once, and the saved session is read and cleared first. The redirect target is checked twice. It must be a local path.
state still guards the flow.HTML sanitizer
The sanitizer is an allowlist.
Kept elements: p, h1 to h6, strong, b, em, i, s, del, u, code, pre, blockquote, ul, ol, li, hr, br, a, img, table, thead, tbody, tr, th, td, input.
Dropped with their children: script, style, iframe, object, embed, template, noscript, svg, math, link, meta, base, form, button, select, textarea, audio, video, source, track, canvas, and a few more. Any other element is unwrapped: the tag goes and the text stays.
Kept attributes:
| Element | Attributes |
|---|---|
a | href, title |
img | src, alt, title, width, height |
code | A class of the form language-* |
input | Only a checkbox, with checked and disabled |
th, td | align (left, center, right) |
Every other attribute goes. This removes on* handlers and style.
URLs. A URL with no scheme, http:, https:, and mailto: pass. data: passes only for image/png, jpeg, gif, webp, and avif. Other schemes go. The check decodes entities and strips control characters first, so javascript: fails. A bad href or src is removed and the element stays.
The sanitizer does not add rel or target to a link.
Secrets
- Keep all secrets in the environment. Keep them out of git.
- The private key and the client secret never reach the browser.
- The session endpoint never returns a token.
Drafts in a public repository
A draft is readable on GitHub when the repository is public. The admin warns about it.