fix(security): scope OAuth callback postMessage targets and re-enable TLS verification on DNS-bypass fetch (#998)
Two findings, neither blocked by anything else:
1. src/app/callback/page.js — the OAuth callback page posted the
{ code, state } payload to window.opener with targetOrigin "*", so any
page that opened the popup against the well-known redirect_uri received
the live OAuth code. The expectedOrigins list was already computed but
never used. Iterate over it and pass the origin per send.
2. open-sse/utils/proxyFetch.js — createBypassRequest() set
rejectUnauthorized: false on the HTTPS request that runs after the
Google-DNS-resolved real-IP fallback (used for cloudcode-pa.googleapis,
GitHub Copilot, Cursor, AWS LLM endpoints). Combined with servername:
parsedUrl.hostname this gave SNI-correct connections that nonetheless
ignored cert validation, so an on-path attacker could swap in their
own cert and read the user's API tokens / prompts. Drop the flag.
Detected by Aeon + semgrep (javascript.browser.security.wildcard-postmessage-configuration
+ problem-based-packs.insecure-transport.js-node.bypass-tls-verification).
Severity: HIGH (#1) / MEDIUM (#2).
CWEs: CWE-1385 (#1), CWE-295 (#2).
Co-authored-by: aeonframework <aeon@aeonframework.dev>
This commit is contained in:
parent
c7c1074f28
commit
52c38cf94c
2 changed files with 23 additions and 8 deletions
|
|
@ -155,8 +155,13 @@ async function createBypassRequest(parsedUrl, realIP, options) {
|
|||
socket.connect(HTTPS_PORT, realIP, () => {
|
||||
const reqOptions = {
|
||||
socket,
|
||||
// SNI + cert hostname are validated against the hostname the caller
|
||||
// asked for, not the IP we connected to. This keeps the DNS-bypass
|
||||
// (avoiding /etc/hosts MITM) while still rejecting on-path attackers
|
||||
// that present a different cert. The MITM_BYPASS_HOSTS targets are
|
||||
// all public-CA-issued (Google / GitHub / AWS / Cursor) so default
|
||||
// verification works without any extra trust store.
|
||||
servername: parsedUrl.hostname,
|
||||
rejectUnauthorized: false,
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: options.method || "POST",
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -26,19 +26,29 @@ function CallbackContent() {
|
|||
|
||||
let relayed = false;
|
||||
|
||||
// Check if this callback is from expected origin/port
|
||||
// Trusted origins that may receive this callback. The OAuth code/state
|
||||
// must only be relayed to the dashboard window we expect to be the opener
|
||||
// (same origin) or the Codex helper that listens on a fixed loopback port.
|
||||
// Any other origin is treated as hostile (drive-by attacker that opened
|
||||
// the popup against the well-known redirect_uri to phish the code).
|
||||
const expectedOrigins = [
|
||||
window.location.origin, // Same origin (for most providers)
|
||||
"http://localhost:1455", // Codex specific port
|
||||
];
|
||||
|
||||
|
||||
// Method 1: postMessage to opener (popup mode)
|
||||
// Send once per expected origin. The browser delivers the message only
|
||||
// when the opener's origin matches the targetOrigin we pass — using "*"
|
||||
// here would leak the code/state to any opener (e.g. an attacker page
|
||||
// that opened this URL in a popup), so iterate over the allowlist.
|
||||
if (window.opener) {
|
||||
try {
|
||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("postMessage failed:", e);
|
||||
for (const origin of expectedOrigins) {
|
||||
try {
|
||||
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, origin);
|
||||
relayed = true;
|
||||
} catch (e) {
|
||||
console.log("postMessage failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue