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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2026-03-26 - [Secure OAuth Redirect PostMessage]
**Vulnerability:** Insecure cross-document communication using `window.postMessage` with wildcard target origin (`'*'`) and loose origin verification (`startsWith`) on the receiver side.
**Learning:** OAuth2 callback flows often use `postMessage` to transmit authorization codes from a temporary redirect window back to the main application. Using a wildcard target origin allows any origin (including malicious ones) to intercept the code if they can position themselves as the window opener. Similarly, loose origin verification on the receiver side (using `startsWith`) can be bypassed (e.g., `https://trusted.com.malicious.com`).
**Prevention:** Always use a specific `targetOrigin` when calling `postMessage`. In multi-tenant environments, resolve the trusted frontend origin dynamically (e.g., via `domainHelper` and platform context). On the receiver side, perform strict equality checks on `event.origin` against a trusted, pre-configured 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
21 changes: 13 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,19 @@ 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);
try {
const expectedOrigin = new URL(redirectUrl).origin;
if (
redirectUrl &&
event.origin === expectedOrigin &&
event.data['code']
) {
resolve(decodeURIComponent(event.data.code));
currentPopup?.close();
window.removeEventListener('message', handler);
}
} catch (e) {
// ignore
}
});
});
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 Derive redirect postMessage origin from tenant context

This computes targetOrigin from the /redirect request context, which breaks OAuth popups when the callback URL is served from INTERNAL_URL but the opener is on a tenant/custom frontend domain. In that configuration, federatedAuthnService.getThirdPartyRedirectUrl returns INTERNAL_URL/redirect and domainHelper.getInternalUrl ignores platformId, so getPlatformIdForRequest on this route cannot recover the tenant and getPublicUrl({ platformId: null }) falls back to the global FRONTEND_URL; the subsequent postMessage is sent to the wrong origin and the code is never delivered to the opener. This regression only appears in deployments with differing internal/public domains, but it causes OAuth login/connection flows to hang for affected tenants.

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>`,
Comment on lines +254 to +262
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 | 🟠 Major

Silent failure when platform cannot be determined for custom domain requests.

When getPlatformIdForRequest returns null (e.g., CLOUD edition with unknown hostname per platform.utils.ts:19), domainHelper.getPublicUrl falls back to the default FRONTEND_URL. If the actual opener is on a custom domain, the computed targetOrigin won't match, and postMessage will silently fail to deliver—the OAuth flow hangs without error feedback.

Consider logging a warning when platformId is null to aid debugging, or validating that the computed origin aligns with the request's origin.

Suggested improvement
 const platformId = await platformUtils.getPlatformIdForRequest(request)
+if (platformId === null) {
+    request.log.warn({ host: request.headers.host }, 'Could not determine platform for OAuth redirect, falling back to default frontend URL')
+}
 const frontendUrl = await domainHelper.getPublicUrl({ platformId })
 const targetOrigin = new URL(frontendUrl).origin
📝 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
const platformId = await platformUtils.getPlatformIdForRequest(request)
const frontendUrl = await domainHelper.getPublicUrl({ platformId })
const targetOrigin = new URL(frontendUrl).origin
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>`,
const platformId = await platformUtils.getPlatformIdForRequest(request)
if (platformId === null) {
request.log.warn({ host: request.headers.host }, 'Could not determine platform for OAuth redirect, falling back to default frontend URL')
}
const frontendUrl = await domainHelper.getPublicUrl({ platformId })
const targetOrigin = new URL(frontendUrl).origin
return reply
.type('text/html')
.send(
`<script>if(window.opener){window.opener.postMessage({ 'code': '${encodeURIComponent(
params.code,
)}' }, '${targetOrigin}')}</script> <html>Redirect succuesfully, this window should close now</html>`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/api/src/app/app.ts` around lines 254 - 262, When
platformUtils.getPlatformIdForRequest(request) returns null, log a warning
(including request.hostname and request.headers.origin) and validate that
domainHelper.getPublicUrl({ platformId })'s origin (targetOrigin) matches the
actual request origin; if they differ, use the request origin as a safer
fallback for targetOrigin or return an error reply indicating mismatched
origins. Update the handler around platformId/targetOrigin (the block computing
frontendUrl/targetOrigin and sending reply) to emit the warning when platformId
is null and to choose request.headers.origin as the postMessage target if it
produces a different origin than new URL(frontendUrl).origin.

)
}
},
Expand Down
Loading
Loading