mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
A second review pass, run against the previous commit, found four blockers — two of
them introduced by the fixes in that commit. It also confirmed the original account
takeover is closed: a hostile IdP with real TLS, discovery, JWKS and RS256 driving the
real routers now stops at domain_not_allowed, and all 16 bypass variants are refused.
DOMAIN OWNERSHIP (the root cause, not the symptom)
A claimed domain used to mean "nobody else claimed it". It now means the organization
published a record in that domain's own DNS — TXT or CNAME, at a dedicated
_screentinker-verify name rather than the apex, where an edit would sit beside SPF.
- an unverified domain routes NOBODY and cannot be asserted; it reserves the name
- an unverified claim LAPSES after 8 hours, so a domain cannot be held against its
real owner, and lapsing rotates the token so a record left over from an abandoned
attempt cannot satisfy a later claim
- a verified domain never expires — re-proving on a timer would log a customer out
over a DNS edit made months later
- routing and confinement read the VERIFIED set only, never the typed column
- configuring SSO now requires a verified email address
- platform admins are emailed when a domain is claimed; nothing is ever sent to the
claimed domain, which would let any tenant make this product email third parties
Instance-wide providers are exempt from all of it: they are the operator's own
configuration and keep the trust they have always had.
BLOCKERS FROM THE REVIEW
- two unauthenticated remote crashes, both one request, both "async handler throws
before its try": `Cookie: st_oidc_tx=%` (unguarded decodeURIComponent) and the
fail-closed secret added last commit, which turned a JWT_SECRET rotation into a
permanent crash loop. Fixed the CLASS with asyncRoute() rather than the instances.
- the SSRF guard was bypassable via IPv4-mapped IPv6 ([::ffff:127.0.0.1]) and also
refused every host beginning "fc"/"fd" (fcm.googleapis.com). Addresses are now
parsed and compared by RANGE. 42 cases verified.
- the takeover fix had NO test — the test named after it asserted two struct fields
and passed with the guard deleted. The decision is now a pure function and four
mutations were confirmed to turn the suite red.
- the PUT path never received the TOCTOU fix, so two orgs could end up holding one
domain and forEmail handed routing to the attacker's older row.
ALSO
- linking compared slugs, so an org could never rotate its own IdP, and fell open on
an empty auth_provider. It now asks which ORGANIZATION owns the slug.
- an account stranded by a deleted provider can be reclaimed by password reset —
proof of the mailbox, which is stronger than the IdP assertion that created it.
- /sso/claim accepted a pre-TOTP mfa_pending token and returned the full user row;
it now takes a purpose-built 120s claim token with a pinned algorithm and typ.
- the rate limiter keyed on a caller-controlled path, so a trailing slash bought a
fresh bucket — a real login brute-force bypass.
- domain_not_allowed and account_exists_other_provider rendered as "please try
again", advice that can never work.
- malformed asserted addresses are refused rather than trimmed into shape.
- dead config (microsoftTenantId defaulted to 'common', which the provider code now
refuses) and the orphaned google-auth-library dependency removed.
1591 tests pass. Domain lifecycle verified end to end against a running server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
327 lines
14 KiB
JavaScript
327 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* OpenID Connect — discovery, key handling and ID-token verification.
|
|
*
|
|
* This exists because the previous "OAuth" support verified nothing that mattered. The Google path
|
|
* asked Google's tokeninfo endpoint whether an ACCESS token was valid and then trusted the email in
|
|
* the reply; the Microsoft path handed a bearer token to Graph /me and trusted that. Neither ever
|
|
* checked WHO THE TOKEN WAS ISSUED FOR, and an access token is not a proof of identity — it is a
|
|
* bearer credential for some resource, minted for some application, and Graph will happily describe
|
|
* the user behind a token issued to somebody else's app. Any site a user signs into that asks for
|
|
* `email` or `User.Read` could replay that token here and be issued a session as that user.
|
|
*
|
|
* So identity now comes from an ID TOKEN and nothing else, and the token has to survive:
|
|
*
|
|
* signature against the provider's published JWKS, restricted to asymmetric algorithms
|
|
* iss exactly the issuer discovery advertised
|
|
* aud contains our client_id (and azp === client_id when the token carries one)
|
|
* exp/nbf inside a small clock skew
|
|
* nonce equal to the one WE generated for this login, which is what stops a token
|
|
* obtained elsewhere — even a correctly-audienced one — being replayed here
|
|
*
|
|
* Deliberately dependency-free beyond `jsonwebtoken`: Node can import a JWK straight into a
|
|
* KeyObject, so there is no need for jwks-rsa and no second opinion about what a key is.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const net = require('net');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
/*
|
|
* `alg: "none"` is the oldest JWT attack there is, and HMAC is nearly as bad here: an HS256 token is
|
|
* verified with a SHARED SECRET, and the only "key" we have for a provider is its PUBLIC one — which
|
|
* an attacker also has, and could sign with. Only asymmetric families are ever acceptable.
|
|
*/
|
|
const ALLOWED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512'];
|
|
|
|
// Providers rotate keys and publish new ones ahead of use, so a short cache is safe and a miss is
|
|
// cheap. Discovery changes far less often but is cached the same way for one reason: a provider
|
|
// outage should not be able to stall every login for as long as it lasts.
|
|
const DISCOVERY_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
const FETCH_TIMEOUT_MS = 8000;
|
|
|
|
const discoveryCache = new Map(); // issuer -> { at, doc }
|
|
const jwksCache = new Map(); // jwks_uri -> { at, keys }
|
|
|
|
/*
|
|
* Every URL this module fetches is ultimately chosen by whoever configured the provider — and since
|
|
* per-org SSO, that is a CUSTOMER, not the operator. Discovery, JWKS and the token endpoint are
|
|
* therefore server-side request forgery primitives unless they are constrained.
|
|
*
|
|
* Two rules, both cheap:
|
|
* https only — an http:// target is a plaintext credential leak as well as a way to reach
|
|
* services that never expected a request from inside the network.
|
|
* public hosts only — loopback, RFC1918, CGNAT, link-local (169.254.169.254 is cloud metadata),
|
|
* multicast and reserved ranges, in BOTH address families, including the
|
|
* IPv4-mapped IPv6 forms that a prefix match misses.
|
|
*
|
|
* ⚠️ This is a literal-address check, not full SSRF protection: a hostname that RESOLVES to a
|
|
* private address still passes, because refusing that needs resolve-then-pin plumbing that Node's
|
|
* fetch does not expose. It raises the bar from "type an internal URL" to "control public DNS".
|
|
* README.md documents this limitation under per-organization SSO.
|
|
*/
|
|
/*
|
|
* Addresses are parsed as ADDRESSES and compared by range. This started life as a prefix regex,
|
|
* which was wrong in both directions: it missed `[::ffff:127.0.0.1]` — the entire IPv4 space
|
|
* re-encoded, which WHATWG URL normalises to `[::ffff:7f00:1]` so no dotted-quad prefix can match,
|
|
* and a review reached a loopback service straight through it — while also matching plain TEXT, so
|
|
* every hostname beginning "fc" or "fd" was refused (fcm.googleapis.com, fcps.edu).
|
|
*/
|
|
const BLOCKED_V4 = [
|
|
['0.0.0.0', 8], // "this network"
|
|
['10.0.0.0', 8], // RFC1918
|
|
['100.64.0.0', 10], // CGNAT / Tailscale
|
|
['127.0.0.0', 8], // loopback
|
|
['169.254.0.0', 16], // link-local — 169.254.169.254 is cloud metadata
|
|
['172.16.0.0', 12], // RFC1918
|
|
['192.0.0.0', 24], // IETF protocol assignments
|
|
['192.168.0.0', 16], // RFC1918
|
|
['198.18.0.0', 15], // benchmarking
|
|
['224.0.0.0', 4], // multicast
|
|
['240.0.0.0', 4], // reserved
|
|
];
|
|
|
|
const v4ToInt = (ip) => ip.split('.').reduce((acc, o) => (acc * 256) + Number(o), 0);
|
|
|
|
function isBlockedV4(ip) {
|
|
const addr = v4ToInt(ip);
|
|
return BLOCKED_V4.some(([base, bits]) => {
|
|
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
|
|
return (addr & mask) >>> 0 === (v4ToInt(base) & mask) >>> 0;
|
|
});
|
|
}
|
|
|
|
function isBlockedV6(ip) {
|
|
const low = ip.toLowerCase();
|
|
// An IPv4-mapped or IPv4-compatible address is an IPv4 address wearing a hat — judge the IPv4.
|
|
const mapped = low.match(/^::(ffff:)?(\d+\.\d+\.\d+\.\d+)$/)
|
|
|| low.match(/^::(ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
if (mapped) {
|
|
if (mapped[2] && mapped[2].includes('.')) return isBlockedV4(mapped[2]);
|
|
const hi = parseInt(mapped[2], 16), lo = parseInt(mapped[3], 16);
|
|
return isBlockedV4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.'));
|
|
}
|
|
if (low === '::' || low === '::1') return true; // unspecified (= loopback on Linux), loopback
|
|
if (/^f[cd]/.test(low)) return true; // fc00::/7 unique-local
|
|
if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local
|
|
if (/^ff/.test(low)) return true; // multicast
|
|
return false;
|
|
}
|
|
|
|
function assertFetchable(url) {
|
|
let u;
|
|
try { u = new URL(url); } catch { throw new Error(`not a URL: ${url}`); }
|
|
if (u.protocol !== 'https:') throw new Error('provider URLs must use https');
|
|
|
|
const host = u.hostname;
|
|
// URL keeps IPv6 literals in brackets; net.isIP does not want them.
|
|
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
const family = net.isIP(bare);
|
|
|
|
const blocked = family === 4 ? isBlockedV4(bare)
|
|
: family === 6 ? isBlockedV6(bare)
|
|
: /^(localhost|.*\.localhost)$/i.test(host);
|
|
|
|
if (blocked) throw new Error('provider host is not publicly routable');
|
|
return u;
|
|
}
|
|
|
|
/** fetch with a timeout, because a hanging IdP must not hang a login forever. */
|
|
async function getJson(url) {
|
|
assertFetchable(url);
|
|
const ctl = new AbortController();
|
|
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
/*
|
|
* redirect: 'manual' — following redirects would let an allowlisted host bounce us to a blocked
|
|
* one, which defeats the check above entirely. A provider that redirects its own well-known
|
|
* document is misconfigured, and saying so is more useful than quietly following it.
|
|
*/
|
|
const res = await fetch(url, { signal: ctl.signal, redirect: 'manual' });
|
|
if (res.status >= 300 && res.status < 400) throw new Error(`${url} redirected; provider URLs must be final`);
|
|
if (!res.ok) throw new Error(`${url} responded ${res.status}`);
|
|
return await res.json();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The provider's own description of itself.
|
|
*
|
|
* ⚠️ The discovered `issuer` is checked against the configured one. Discovery is fetched over TLS
|
|
* from a URL derived from the issuer, so this is belt-and-braces — but a provider whose document
|
|
* claims a DIFFERENT issuer is either misconfigured or hostile, and either way its tokens must not
|
|
* be accepted under a name it does not own.
|
|
*/
|
|
async function discover(issuer) {
|
|
const key = String(issuer).replace(/\/+$/, '');
|
|
const hit = discoveryCache.get(key);
|
|
if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.doc;
|
|
|
|
const url = `${key}/.well-known/openid-configuration`;
|
|
const doc = await getJson(url);
|
|
|
|
const advertised = String(doc.issuer || '').replace(/\/+$/, '');
|
|
if (advertised !== key) {
|
|
throw new Error(`discovery issuer mismatch: configured ${key}, document says ${doc.issuer}`);
|
|
}
|
|
for (const required of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) {
|
|
if (!doc[required]) throw new Error(`discovery for ${key} is missing ${required}`);
|
|
}
|
|
|
|
discoveryCache.set(key, { at: Date.now(), doc });
|
|
return doc;
|
|
}
|
|
|
|
/**
|
|
* The signing key for one token.
|
|
*
|
|
* An unknown `kid` forces ONE refresh: that is the normal shape of a key rotation, and refusing to
|
|
* refetch would fail every login until the cache expired. It is bounded to one refresh per call so
|
|
* a token quoting nonsense cannot be used to hammer the provider.
|
|
*/
|
|
async function keyForKid(jwksUri, kid) {
|
|
let entry = jwksCache.get(jwksUri);
|
|
const fresh = entry && Date.now() - entry.at < JWKS_TTL_MS;
|
|
|
|
if (!fresh || !entry.keys.some((k) => k.kid === kid)) {
|
|
const doc = await getJson(jwksUri);
|
|
entry = { at: Date.now(), keys: Array.isArray(doc.keys) ? doc.keys : [] };
|
|
jwksCache.set(jwksUri, entry);
|
|
}
|
|
|
|
const jwk = entry.keys.find((k) => k.kid === kid)
|
|
// A provider with exactly one key may omit kid entirely; anything ambiguous is refused rather
|
|
// than guessed, because "try each key until one verifies" is how you accept a key you did not mean to.
|
|
|| (!kid && entry.keys.length === 1 ? entry.keys[0] : null);
|
|
if (!jwk) throw new Error(`no signing key for kid ${kid || '(none)'}`);
|
|
|
|
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
|
}
|
|
|
|
/**
|
|
* Verify an ID token and return its claims.
|
|
*
|
|
* `nonce` is REQUIRED by this function even though the spec makes it conditional. Every flow here
|
|
* is a browser login we initiated, so we always have one to compare — and it is the single check
|
|
* that distinguishes "a token minted for us, now" from "a token minted for us at some point,
|
|
* captured, and replayed".
|
|
*/
|
|
async function verifyIdToken(idToken, { issuer, clientId, nonce }) {
|
|
if (!idToken || typeof idToken !== 'string') throw new Error('no id_token');
|
|
if (!nonce) throw new Error('no nonce to verify against');
|
|
|
|
const decoded = jwt.decode(idToken, { complete: true });
|
|
if (!decoded || !decoded.header) throw new Error('id_token is not a JWT');
|
|
if (!ALLOWED_ALGS.includes(decoded.header.alg)) {
|
|
throw new Error(`refusing id_token algorithm ${decoded.header.alg}`);
|
|
}
|
|
|
|
const doc = await discover(issuer);
|
|
const key = await keyForKid(doc.jwks_uri, decoded.header.kid);
|
|
|
|
// jsonwebtoken checks signature, exp, nbf, iss and aud. The algorithm allowlist is passed
|
|
// explicitly so the header cannot choose how it is verified.
|
|
const claims = jwt.verify(idToken, key, {
|
|
algorithms: ALLOWED_ALGS,
|
|
issuer: doc.issuer,
|
|
audience: clientId,
|
|
clockTolerance: 60,
|
|
});
|
|
|
|
if (claims.nonce !== nonce) throw new Error('id_token nonce does not match this login');
|
|
|
|
/*
|
|
* azp names the party the token was issued TO when it differs from the audience. If it is present
|
|
* it must be us: a token with our client_id merely in a multi-valued `aud`, issued to a different
|
|
* application, is exactly the confused-deputy case this whole file exists to prevent.
|
|
*/
|
|
if (claims.azp && claims.azp !== clientId) {
|
|
throw new Error('id_token was issued to a different application');
|
|
}
|
|
if (!claims.sub) throw new Error('id_token has no subject');
|
|
|
|
return claims;
|
|
}
|
|
|
|
/** PKCE S256. The verifier never leaves us; only its hash goes to the provider. */
|
|
function createPkce() {
|
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
return { verifier, challenge, method: 'S256' };
|
|
}
|
|
|
|
const randomToken = () => crypto.randomBytes(32).toString('base64url');
|
|
|
|
/**
|
|
* Exchange the authorization code.
|
|
*
|
|
* PKCE means a public client needs no secret, which is what lets a self-hoster configure a provider
|
|
* without one. A secret is still sent when configured, because some providers (and some admins)
|
|
* require confidential clients.
|
|
*/
|
|
async function exchangeCode({ issuer, clientId, clientSecret, code, redirectUri, verifier }) {
|
|
const doc = await discover(issuer);
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
client_id: clientId,
|
|
code_verifier: verifier,
|
|
});
|
|
|
|
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' };
|
|
if (clientSecret) {
|
|
// client_secret_basic is the form every provider accepts; client_secret_post is not universal.
|
|
headers.Authorization = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64');
|
|
}
|
|
|
|
const ctl = new AbortController();
|
|
const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
|
let payload;
|
|
try {
|
|
assertFetchable(doc.token_endpoint);
|
|
const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal, redirect: 'manual' });
|
|
payload = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
// The provider's own error is far more useful than "exchange failed" — a wrong redirect_uri
|
|
// or an unregistered client is the overwhelmingly common setup mistake and it says so here.
|
|
throw new Error(payload.error_description || payload.error || `token endpoint responded ${res.status}`);
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
if (!payload.id_token) throw new Error('provider returned no id_token — is the openid scope requested?');
|
|
return payload;
|
|
}
|
|
|
|
/** Test seam: drop cached discovery/JWKS so a test can change what a provider claims. */
|
|
function _resetCaches() {
|
|
discoveryCache.clear();
|
|
jwksCache.clear();
|
|
}
|
|
|
|
/**
|
|
* The provider's published keys, straight from the document. Used by the configuration test so an
|
|
* admin learns at setup time that a provider publishes no signing keys, rather than at first login.
|
|
*/
|
|
async function fetchJwks(jwksUri) {
|
|
return getJson(jwksUri);
|
|
}
|
|
|
|
module.exports = {
|
|
discover,
|
|
assertFetchable,
|
|
fetchJwks,
|
|
verifyIdToken,
|
|
exchangeCode,
|
|
createPkce,
|
|
randomToken,
|
|
ALLOWED_ALGS,
|
|
_resetCaches,
|
|
};
|