fix(ssh-ca): refuse cross-site POSTs on /ssh/sign, /ssh/myrevoke, /ssh/revoke - #89
Conversation
guimard
left a comment
There was a problem hiding this comment.
Reviewed against the core: _isSelfOrigin matches _checkSelfCors (Portal/Main/Run.pm:1491 — scheme + host_port), jsonBodyToObj enforces the content type (Common/PSGI/Request.pm:104), and the sticky error setter makes the delete $req->{error} necessary (Request.pm:75).
One nit: /ssh/revoke isn't exercised by the new tests (the helper is shared, so the risk is low) — a two-line test would pin it.
|
Added: Three cases: form content type → 400, foreign |
488b52d to
a7e11f0
Compare
…h/revoke The three POST routes called from_json on $req->content directly, bypassing the core's jsonBodyToObj and its `Content-Type: application/json` check. An HTML form can only send urlencoded, multipart or text/plain bodies — and a text/plain body holding JSON parsed cleanly — so a page on another origin could POST with the victim's cookie wherever the SSO cookie is SameSite=None (which LLNG sets for SAML deployments). Nothing leaks that way: the certificate is only in a response body the attacker's page cannot read, and it is never persisted. The damage is a forced state change — re-signing a victim's public key supersedes their live certificate and KRL-revokes the old serial, locking them out of SSH. Body decoding now goes through one helper that enforces the content type and refuses a present-but-foreign Origin. Non-browser callers (the ssh-ca CLI, curl, ob-* tooling) send no Origin and are unaffected. Closes #62 Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
The helper is shared with /ssh/sign and /ssh/myrevoke, but /ssh/revoke was the one route the new tests did not exercise: it sits behind sshCaAdminRule, so it needs a portal where the caller IS the administrator for the refusals to prove anything about the CSRF gate rather than about _forbidNonAdmin. 02-SSHCA-admin.t already has such a portal. Reported in review of #89. Claude-Session: https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL
f3dcd00 to
13873dd
Compare
Closes #62. Stacked on #88 → #87; merge those first.
The hole
The three POST routes parsed their body with
from_json( $req->content ),bypassing
Common/PSGI/Request.pm'sjsonBodyToObjand therefore itsContent-Type: application/jsonrequirement. An HTML form can only sendapplication/x-www-form-urlencoded,multipart/form-dataortext/plain, anda
text/plainbody containing JSON parsed cleanly — so a cross-site form POSTwas accepted. There is no CSRF token for plugin REST routes in the core.
Reachable only where the SSO cookie is
SameSite=None, which LLNG sets whenissuerDBSAMLActivationorsamlIDPMetaDataXMLis set. No shippedopen-bastion configuration does, so as delivered this was not exploitable.
What it was worth to an attacker
Not the certificate: it is returned only in a response body a cross-origin page
cannot read (
Access-Control-Allow-Originis emitted only for the portal's ownorigin), and it is never persisted — only serial, key_id, label, fingerprint,
principals and dates are.
The residual is a forced state change: re-signing a victim's publicly known
public key supersedes their live certificate and KRL-revokes the old serial,
locking them out of SSH across the fleet. A forced
/ssh/myrevokeis the sameshape in one step.
The fix
One helper,
_jsonBodyOrReject, used by all three routes:Originpresent and not the portal's own → 403, audited asSSH_CA_CROSS_ORIGIN_REFUSED. Browsers attachOriginto everycross-origin POST; non-browser callers (the ssh-ca CLI, curl,
ob-*tooling) send none and are unaffected.
$req->jsonBodyToObj, soContent-Type: application/jsonisrequired → 400 otherwise.
Compatibility
Any caller already sending
Content-Type: application/json— which is what theplugin has always documented, and what the shipped
/sshpage and the CLI do —is unaffected. A caller posting JSON under another content type now gets 400.
Tests
ssh-ca 570 assertions, green. New coverage in t/01:
text/plainandurlencoded bodies refused on
/ssh/signand/ssh/myrevoke, a foreignOriginrefused, and the portal's ownOriginstill served.https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL