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
11 changes: 11 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## 2025-05-15 - [HIGH] Secure OAuth2 Redirect postMessage

**Vulnerability:** The application was using the wildcard `*` as the `targetOrigin` in `window.postMessage` during the OAuth2 redirect flow, and it was using `startsWith` for origin verification when receiving the message.

**Learning:**
1. Using `*` as a target origin allows any malicious site that can get a reference to the window (e.g., via `window.opener` if the popup was opened from another site or if the site was compromised) to intercept the sensitive OAuth code.
2. Using `startsWith` for origin validation is insecure because it can be bypassed. For example, `https://example.com.attacker.com` starts with `https://example.com`.

**Prevention:**
1. Always specify a precise `targetOrigin` when calling `postMessage`. In the frontend, use `window.location.origin`. On the server, resolve the platform's public URL and extract the origin.
2. Always use strict equality (`===`) when verifying `event.origin` in a message listener to ensure it exactly matches the expected trusted origin.
2 changes: 1 addition & 1 deletion packages/react-ui/src/app/routes/redirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const RedirectPage: React.FC = React.memo(() => {
{
code: code,
},
'*',
window.location.origin,
);
}
}, [location.search]);
Expand Down
20 changes: 12 additions & 8 deletions packages/react-ui/src/lib/oauth2-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,18 @@ function constructUrl(params: OAuth2PopupParams, pckeChallenge: string) {
function getCode(redirectUrl: string): Promise<string> {
return new Promise<string>((resolve) => {
window.addEventListener('message', function handler(event) {
if (
redirectUrl &&
redirectUrl.startsWith(event.origin) &&
event.data['code']
) {
resolve(decodeURIComponent(event.data.code));
currentPopup?.close();
window.removeEventListener('message', handler);
if (!redirectUrl) {
return;
}
try {
const expectedOrigin = new URL(redirectUrl).origin;
if (event.origin === expectedOrigin && event.data['code']) {
resolve(decodeURIComponent(event.data.code));
currentPopup?.close();
window.removeEventListener('message', handler);
}
Comment on lines +80 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how redirectUrl is constructed for CLOUD_OAUTH2 vs other types
# to understand the cross-origin scenarios

echo "=== Checking OAuth connection types and redirect URL construction ==="
rg -n -A5 -B2 "CLOUD_OAUTH2" --type ts

echo ""
echo "=== Checking how thirdPartyUrl/redirectUrl is passed to oauth2Utils ==="
rg -n -A3 "openOAuth2Popup|openWithLoginUrl|getCode" --type ts -g '!*.test.*' -g '!*.spec.*'

Repository: AGI-Corporation/Route.X

Length of output: 24928


🏁 Script executed:

# First, let's find the oauth2-utils.ts file and read the complete getCode function
find . -name "oauth2-utils.ts" -type f | head -5

Repository: AGI-Corporation/Route.X

Length of output: 110


🏁 Script executed:

# Let's also find redirect.tsx files
find . -name "redirect.tsx" -type f

Repository: AGI-Corporation/Route.X

Length of output: 114


🏁 Script executed:

# Read the complete oauth2-utils.ts file to understand the full context
cat -n packages/react-ui/src/lib/oauth2-utils.ts

Repository: AGI-Corporation/Route.X

Length of output: 3447


🏁 Script executed:

cat -n packages/react-ui/src/app/routes/redirect.tsx

Repository: AGI-Corporation/Route.X

Length of output: 1219


Fix the postMessage target origin in redirect.tsx to match the opener's origin.

The listener in oauth2-utils.ts expects messages from https://secrets.activepieces.com, but the actual problem is in redirect.tsx (line 12-17): it posts with targetOrigin: window.location.origin (its own origin at https://secrets.activepieces.com).

When the popup posts a message to the opener on tenant.activepieces.com with targetOrigin: 'https://secrets.activepieces.com', the browser blocks it because the opener is not on that domain. The popup should post to the opener's origin instead, either via window.opener.location.origin or '*' for compatibility across all tenant domains.

This will silently fail in CLOUD_OAUTH2 flowsβ€”the popup sends the message, but the browser discards it before the opener receives it.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-ui/src/lib/oauth2-utils.ts` around lines 80 - 86, The popup's
postMessage targetOrigin in redirect.tsx is incorrectβ€”change the postMessage
call (the one currently using window.location.origin) to target the opener's
origin instead (use window.opener?.location?.origin if available, falling back
to '*' if not) and guard that window.opener exists before posting; this will
match the listener in oauth2-utils.ts which validates event.origin (the
expectedOrigin computed from redirectUrl) and prevent the browser from
discarding the message.

} catch (e) {
console.error('Error parsing redirectUrl origin', e);
}
});
});
Comment on lines 74 to 91
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor

Origin verification is now secure, but the promise can hang indefinitely.

The strict equality check (===) correctly prevents origin bypass attacks. However, several scenarios cause the promise to never resolve:

  1. Empty redirectUrl: Early return at lines 77-79 leaves the listener active but the promise unresolved
  2. URL parsing failure: The catch block logs but doesn't reject
  3. Origin mismatch: If event.origin !== expectedOrigin, the message is ignored and the promise hangs

Consider adding a timeout or rejection mechanism to prevent the OAuth flow from hanging indefinitely when something goes wrong.

πŸ’‘ Suggested improvement with timeout
 function getCode(redirectUrl: string): Promise<string> {
-  return new Promise<string>((resolve) => {
-    window.addEventListener('message', function handler(event) {
+  return new Promise<string>((resolve, reject) => {
+    const timeoutId = setTimeout(() => {
+      window.removeEventListener('message', handler);
+      reject(new Error('OAuth code retrieval timed out'));
+    }, 120000); // 2 minute timeout
+
+    function handler(event: MessageEvent) {
       if (!redirectUrl) {
+        clearTimeout(timeoutId);
+        window.removeEventListener('message', handler);
+        reject(new Error('redirectUrl is required'));
         return;
       }
       try {
         const expectedOrigin = new URL(redirectUrl).origin;
         if (event.origin === expectedOrigin && event.data['code']) {
+          clearTimeout(timeoutId);
           resolve(decodeURIComponent(event.data.code));
           currentPopup?.close();
           window.removeEventListener('message', handler);
         }
       } catch (e) {
         console.error('Error parsing redirectUrl origin', e);
+        clearTimeout(timeoutId);
+        window.removeEventListener('message', handler);
+        reject(e);
       }
-    });
+    }
+
+    window.addEventListener('message', handler);
   });
 }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getCode(redirectUrl: string): Promise<string> {
return new Promise<string>((resolve) => {
window.addEventListener('message', function handler(event) {
if (
redirectUrl &&
redirectUrl.startsWith(event.origin) &&
event.data['code']
) {
resolve(decodeURIComponent(event.data.code));
currentPopup?.close();
window.removeEventListener('message', handler);
if (!redirectUrl) {
return;
}
try {
const expectedOrigin = new URL(redirectUrl).origin;
if (event.origin === expectedOrigin && event.data['code']) {
resolve(decodeURIComponent(event.data.code));
currentPopup?.close();
window.removeEventListener('message', handler);
}
} catch (e) {
console.error('Error parsing redirectUrl origin', e);
}
});
});
function getCode(redirectUrl: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const timeoutId = setTimeout(() => {
window.removeEventListener('message', handler);
reject(new Error('OAuth code retrieval timed out'));
}, 120000); // 2 minute timeout
function handler(event: MessageEvent) {
if (!redirectUrl) {
clearTimeout(timeoutId);
window.removeEventListener('message', handler);
reject(new Error('redirectUrl is required'));
return;
}
try {
const expectedOrigin = new URL(redirectUrl).origin;
if (event.origin === expectedOrigin && event.data['code']) {
clearTimeout(timeoutId);
resolve(decodeURIComponent(event.data.code));
currentPopup?.close();
window.removeEventListener('message', handler);
}
} catch (e) {
console.error('Error parsing redirectUrl origin', e);
clearTimeout(timeoutId);
window.removeEventListener('message', handler);
reject(e);
}
}
window.addEventListener('message', handler);
});
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-ui/src/lib/oauth2-utils.ts` around lines 74 - 91, The getCode
promise can hang; update getCode to reject and clean up in failure cases: if
redirectUrl is falsy immediately reject with an Error and remove the message
listener (and close currentPopup), on URL parsing errors reject with that Error
instead of only logging, and add a configurable timeout (e.g., 30s) that
rejects, closes currentPopup, and removes the handler when triggered; ensure the
message handler still resolves on success and also clears the timeout and
removes itself. Reference function getCode, the inner handler, currentPopup, and
the event listener removal logic when implementing these changes.

Expand Down
6 changes: 5 additions & 1 deletion packages/server/api/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import { pieceMetadataServiceHooks } from './pieces/piece-metadata-service/hooks
import { pieceSyncService } from './pieces/piece-sync-service'
import { platformModule } from './platform/platform.module'
import { platformService } from './platform/platform.service'
import { platformUtils } from './platform/platform.utils'
import { projectHooks } from './project/project-hooks'
import { projectModule } from './project/project-module'
import { storeEntryModule } from './store-entry/store-entry.module'
Expand Down Expand Up @@ -250,12 +251,15 @@ export const setupApp = async (app: FastifyInstance): Promise<FastifyInstance> =
return reply.send('The code is missing in url')
}
else {
const platformId = await platformUtils.getPlatformIdForRequest(request)
const frontendUrl = await domainHelper.getPublicUrl({ platformId })
const targetOrigin = new URL(frontendUrl).origin
Comment on lines +254 to +256
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve platform context when deriving postMessage origin

In cloud setups that configure INTERNAL_URL and also use custom domains, this handler can compute the wrong targetOrigin: /redirect is reached on the internal host, platformUtils.getPlatformIdForRequest can return null for that host, and then domainHelper.getPublicUrl falls back to the default FRONTEND_URL; meanwhile the OAuth redirect URI is still allowed to be internal via federatedAuthnService.getThirdPartyRedirectUrl β†’ domainHelper.getInternalUrl, so the popup opener may be on a custom-domain frontend and the browser will drop this postMessage due to origin mismatch, causing login flows to hang.

Useful? React with πŸ‘Β / πŸ‘Ž.

return reply
.type('text/html')
.send(
`<script>if(window.opener){window.opener.postMessage({ 'code': '${encodeURIComponent(
params.code,
)}' },'*')}</script> <html>Redirect succuesfully, this window should close now</html>`,
)}' }, '${targetOrigin}')}</script> <html>Redirect succuesfully, this window should close now</html>`,
)
}
},
Expand Down
Loading
Loading