Skip to content

fix(ssh-ca): refuse cross-site POSTs on /ssh/sign, /ssh/myrevoke, /ssh/revoke - #89

Merged
guimard merged 2 commits into
mainfrom
fix/sshca-csrf
Sep 6, 2026
Merged

guimard merged 2 commits into
mainfrom
fix/sshca-csrf

Conversation

@guimard

@guimard guimard commented Sep 6, 2026

Copy link
Copy Markdown
Member

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's jsonBodyToObj and therefore its
Content-Type: application/json requirement. An HTML form can only send
application/x-www-form-urlencoded, multipart/form-data or text/plain, and
a text/plain body containing JSON parsed cleanly — so a cross-site form POST
was 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 when
issuerDBSAMLActivation or samlIDPMetaDataXML is set. No shipped
open-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-Origin is emitted only for the portal's own
origin), 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/myrevoke is the same
shape in one step.

The fix

One helper, _jsonBodyOrReject, used by all three routes:

  1. Origin present and not the portal's own → 403, audited as
    SSH_CA_CROSS_ORIGIN_REFUSED. Browsers attach Origin to every
    cross-origin POST; non-browser callers (the ssh-ca CLI, curl, ob-*
    tooling) send none and are unaffected.
  2. Body through $req->jsonBodyToObj, so Content-Type: application/json is
    required → 400 otherwise.

Compatibility

Any caller already sending Content-Type: application/json — which is what the
plugin has always documented, and what the shipped /ssh page 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/plain and
urlencoded bodies refused on /ssh/sign and /ssh/myrevoke, a foreign
Origin refused, and the portal's own Origin still served.

https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL

@guimard guimard left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@guimard

guimard commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Added: 02-SSHCA-admin.t PART 7b pins /ssh/revoke (commit 4f15f1f). It had to go there rather than in 01-SSHCA-mycerts.t — the route is behind sshCaAdminRule, so the refusals only prove something about the CSRF gate when the caller is the administrator, which is what that portal gives us (rtyler).

Three cases: form content type → 400, foreign Origin → 403 Cross-origin request refused, portal Origin → passes the gate and reaches the handler (404 on the deliberately unknown session).

https://claude.ai/code/session_01GfBG36HfzjGy8W9rJQbxBL

@guimard
guimard force-pushed the fix/atomic-consumption branch 2 times, most recently from 488b52d to a7e11f0 Compare September 6, 2026 14:21
Base automatically changed from fix/atomic-consumption to main September 6, 2026 14:50
…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
@guimard
guimard merged commit d43d5c2 into main Sep 6, 2026
44 checks passed
@guimard
guimard deleted the fix/sshca-csrf branch September 6, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ssh-ca: POST routes bypass jsonBodyToObj — no Content-Type or Origin check (CSRF under SameSite=None)

1 participant