Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,26 +93,29 @@ async function isAcceptableSamlPost(details) {
if (details.type !== "main_frame") return false;
if (!details.requestBody?.formData) return false;
const trustedSuffixes = await getTrustedSuffixes();
if (isTrustedInitiator(details.initiator, trustedSuffixes)) return true;
// Many IdPs use no-referrer on the SAML auto-submit form, leaving initiator
// undefined. Allow these — STS cryptographically validates the assertion
// signature against the configured IdP, so a forged POST cannot mint creds.
if (!details.initiator) return true;
return false;
// Trusted initiator → safe to auto-assume a single role.
if (isTrustedInitiator(details.initiator, trustedSuffixes)) return { ok: true, trusted: true };
// Missing initiator is common (no-referrer auto-submit forms) but is NOT
// proof of user intent: a replayed/forged top-level POST looks identical.
// Accept the capture but require explicit confirmation before calling STS,
// so a silent assertion-replay cannot mint credentials.
if (!details.initiator) return { ok: true, trusted: false };
return { ok: false, trusted: false };
}

// ─── webRequest listener ──────────────────────────────────────
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
(async () => {
try {
if (!await isAcceptableSamlPost(details)) return;
const verdict = await isAcceptableSamlPost(details);
if (!verdict.ok) return;
const samlField = details.requestBody.formData.SAMLResponse;
if (!samlField || !samlField[0]) return;
// reject oversized assertions before decoding (cap on b64 chars)
if (samlField[0].length > SAML_MAX_B64_CHARS) return;
const china = details.url.startsWith(SAML_ENDPOINT_CHINA);
handleSamlResponse(samlField[0], china).catch(() => {});
handleSamlResponse(samlField[0], china, verdict.trusted).catch(() => {});
} catch (_) {}
})();
},
Expand Down Expand Up @@ -144,20 +147,27 @@ async function offscreenSend(payload) {
}

// ─── SAML → STS flow ─────────────────────────────────────────
async function handleSamlResponse(samlB64, china = false) {
async function handleSamlResponse(samlB64, china = false, trusted = false) {
const samlXml = atobUtf8(samlB64);
const resp = await offscreenSend({ type: "parseSaml", xml: samlXml });
if (!resp?.ok) throw new Error(resp?.error || "saml parse failed");
const roles = resp.roles;
if (!roles || roles.length === 0) throw new Error("No AWS roles in SAML assertion");

if (roles.length === 1) {
// Only auto-assume when the capture came from a trusted initiator. An
// untrusted/no-initiator capture always requires explicit confirmation,
// even for a single role, so a silent replay cannot mint credentials.
if (roles.length === 1 && trusted) {
await assumeAndStore(roles[0], samlB64, china);
return;
}

// Multi-role: encrypt and stash for popup to pick.
const encrypted = await encryptObject({ samlAssertion: samlB64, roles, china, createdAt: Date.now() });
// Stash for the popup to confirm/pick. needsConfirm is true when the capture
// was not from a trusted initiator (this also covers the single-role case).
const encrypted = await encryptObject({
samlAssertion: samlB64, roles, china,
needsConfirm: !trusted, createdAt: Date.now(),
});
await chrome.storage.session.set({ pending: encrypted });
}

Expand Down Expand Up @@ -232,8 +242,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: true, credentialsMap: map, pending: null, sessionExpired });
return;
}
// strip raw SAML assertion — popup only needs roles list
const safePend = pend ? { roles: pend.roles, china: pend.china, createdAt: pend.createdAt } : null;
// strip raw SAML assertion — popup only needs roles list + confirm flag
const safePend = pend ? { roles: pend.roles, china: pend.china, needsConfirm: !!pend.needsConfirm, createdAt: pend.createdAt } : null;
sendResponse({ ok: true, credentialsMap: map, pending: safePend, sessionExpired });
})();
return true;
Expand Down
5 changes: 4 additions & 1 deletion popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,10 @@ async function load() {

const allAssumed = pending?.roles?.length > 0 &&
pending.roles.every(r => credentialsMap[r.roleArn]);
if (pending?.roles?.length > 1 && !allAssumed) {
// Show the confirm/picker UI for any pending capture that needs confirmation
// (untrusted/no-initiator, incl. single role) or any multi-role capture.
const needsConfirm = pending?.needsConfirm || (pending?.roles?.length > 1);
if (pending?.roles?.length >= 1 && needsConfirm && !allAssumed) {
renderPicker(pending, credentialsMap);
$("picker-block").hidden = false;
$("empty-block").hidden = true;
Expand Down