From 9e7f38f47301f59438fbeff1ca4d4f9ebbeef67c Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 17 May 2026 12:28:32 -0700 Subject: [PATCH] fix: validate private apps token too (#3124) --- .../http/middleware/privateAppGate.test.ts | 118 ++++++++++++++++++ .../core/http/middleware/privateAppGate.ts | 61 ++++++--- 2 files changed, 165 insertions(+), 14 deletions(-) diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index 0a93bcfb2..49126e7dd 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -695,6 +695,124 @@ describe('resolvePrivateIdentity', () => { expect(out.source).toBe('none'); expect(out.userUid).toBeUndefined(); }); + + it("ignores req.actor whose actor.app.uid doesn't match the target app", async () => { + // Cross-app token confusion guard: an app-under-user actor whose + // issuing app is NOT the private host's app must not establish + // identity here — even though the underlying user may have legit + // entitlement to the target app, accepting the actor would let + // attacker-app JS replay a victim's app-under-user token against + // an unrelated private host. + const user = await makeUser(); + const out = await resolvePrivateIdentity({ + req: reqOf({ + cookies: {}, + actor: { + user: { uuid: user.uuid }, + app: { uid: `app-attacker-${uuidv4()}` }, + session: { uid: 'sess-1' }, + }, + }), + authService, + sessionCookieName: 'puter_auth_token', + expectedAppUid: `app-target-${uuidv4()}`, + }); + expect(out.source).toBe('none'); + expect(out.userUid).toBeUndefined(); + }); + + it("ignores a bootstrap query-token whose actor.app.uid doesn't match the target app", async () => { + // Same guard for the `?puter.auth.token=` bootstrap path. A token + // minted via getUserAppToken for the attacker's app carries + // app_uid=attacker; using it as a bootstrap on the target host + // must fall through to source='none' (login bootstrap), not + // promote it to a victim identity on the target app. Both app + // rows are seeded so authenticateFromToken returns a real actor + // — proving the guard (not a missing row) is what blocks it. + const user = await makeUser(); + // App uids must be `app-` — TokenService compresses the + // `app_uid` field by stripping the `app-` prefix and decoding the + // rest as a hex-encoded UUID (see TokenService AUTH_COMPRESSION). + // Custom non-uuid suffixes round-trip incorrectly and break + // authenticateFromToken's app lookup. + const attackerUid = `app-${uuidv4()}`; + const targetUid = `app-${uuidv4()}`; + // index_url is NOT NULL in the schema, so supply placeholders. + await server.clients.db.write( + `INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`) + VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)`, + [ + attackerUid, + `attacker-${attackerUid}`, + `attacker-${attackerUid}`, + `http://${attackerUid}.example/`, + user.id, + 0, + targetUid, + `target-${targetUid}`, + `target-${targetUid}`, + `http://${targetUid}.example/`, + user.id, + 1, + ], + ); + const wrongAppToken = authService.getUserAppToken( + { user: { id: user.id, uuid: user.uuid } } as unknown as Parameters< + typeof authService.getUserAppToken + >[0], + attackerUid, + ); + const out = await resolvePrivateIdentity({ + req: reqOf({ + cookies: {}, + query: { 'puter.auth.token': wrongAppToken }, + }), + authService, + sessionCookieName: 'puter_auth_token', + expectedAppUid: targetUid, + }); + expect(out.source).toBe('none'); + expect(out.userUid).toBeUndefined(); + }); + + it('accepts a bootstrap query-token whose actor.app.uid matches the target app', async () => { + // Positive case: a token correctly bound to the target app + // resolves the user identity. Confirms the guard isn't blocking + // legitimate same-app traffic. + const user = await makeUser(); + // App uids must be `app-` for token round-trip — see the + // negative-case comment above. + const targetUid = `app-${uuidv4()}`; + await server.clients.db.write( + `INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + targetUid, + `target-${targetUid}`, + `target-${targetUid}`, + `http://${targetUid}.example/`, + user.id, + 1, + ], + ); + const matchedToken = authService.getUserAppToken( + { user: { id: user.id, uuid: user.uuid } } as unknown as Parameters< + typeof authService.getUserAppToken + >[0], + targetUid, + ); + const out = await resolvePrivateIdentity({ + req: reqOf({ + cookies: {}, + query: { 'puter.auth.token': matchedToken }, + }), + authService, + sessionCookieName: 'puter_auth_token', + expectedAppUid: targetUid, + }); + expect(out.source).toBe('query'); + expect(out.userUid).toBe(user.uuid); + }); }); describe('resolvePublicHostedIdentity', () => { diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index 4aed8cacb..c135f6168 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -17,9 +17,11 @@ * along with this program. If not, see . */ +import type { AbstractDatabaseClient } from '@heyputer/backend/src/clients/database/DatabaseClient'; import type { Request } from 'express'; import type { AuthService } from '../../../services/auth/AuthService'; import type { IConfig } from '../../../types'; +import type { Actor } from '../../actor'; /** * Support helpers for the private-app access gate — ported from v1's @@ -87,18 +89,12 @@ interface AppLike { id?: number; uid?: string; name?: string; + title?: string; owner_user_id?: number; is_private?: boolean | number | null; index_url?: string | null; } -interface DBClient { - read: ( - sql: string, - params: unknown[], - ) => Promise[]>; -} - // ── Host helpers ──────────────────────────────────────────────────── export function normalizeHost(value: string | undefined | null): string | null { @@ -188,7 +184,7 @@ export async function resolvePrivateAppForHostedSite(opts: { req: Request; site: SubdomainLike; associatedApp: AppLike | null; - db: DBClient; + db: AbstractDatabaseClient; config: PrivateHostingConfig; matchedHostingDomain: string; }): Promise { @@ -381,7 +377,10 @@ export async function resolvePrivateIdentity(opts: { // 2. Auth probe actor. const existingActor = req.actor; - if (existingActor?.user?.uuid) { + if ( + existingActor?.user?.uuid && + actorMatchesExpectedApp(existingActor, expectedAppUid) + ) { return { source: 'session-cookie', userUid: existingActor.user.uuid, @@ -397,7 +396,10 @@ export async function resolvePrivateIdentity(opts: { if (sessionToken) { try { const actor = await authService.authenticateFromToken(sessionToken); - if (actor?.user?.uuid) { + if ( + actor?.user?.uuid && + actorMatchesExpectedApp(actor, expectedAppUid) + ) { return { source: 'session-cookie', userUid: actor.user.uuid, @@ -416,7 +418,10 @@ export async function resolvePrivateIdentity(opts: { const actor = await authService.authenticateFromToken( bootstrap.token, ); - if (actor?.user?.uuid) { + if ( + actor?.user?.uuid && + actorMatchesExpectedApp(actor, expectedAppUid) + ) { return { source: bootstrap.source, userUid: actor.user.uuid, @@ -431,6 +436,25 @@ export async function resolvePrivateIdentity(opts: { return { source: 'none' }; } +/** + * Guard against token confusion across private-app boundaries: an actor + * derived from an app-under-user token carries the *issuing* app's uid, + * which must match the host the request is being made against. A token + * minted for app A — e.g. when the visitor authorized a third-party app — + * must not be honored as identity on app B's private host, even when the + * underlying user happens to have entitlement to B. User-only actors + * (no `actor.app`) are unaffected: a plain session token is portable by + * design. + */ +function actorMatchesExpectedApp( + actor: Actor, + expectedAppUid: string | undefined, +): boolean { + if (!expectedAppUid) return true; + if (!actor.app?.uid) return true; + return actor.app.uid === expectedAppUid; +} + /** * Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the * sticky `puter.public.hosted.actor.token` cookie first, then the same @@ -483,7 +507,10 @@ export async function resolvePublicHostedIdentity(opts: { } const existingActor = req.actor; - if (existingActor?.user?.uuid) { + if ( + existingActor?.user?.uuid && + actorMatchesExpectedApp(existingActor, expectedAppUid) + ) { return { source: 'session-cookie', userUid: existingActor.user.uuid, @@ -498,7 +525,10 @@ export async function resolvePublicHostedIdentity(opts: { if (sessionToken) { try { const actor = await authService.authenticateFromToken(sessionToken); - if (actor?.user?.uuid) { + if ( + actor?.user?.uuid && + actorMatchesExpectedApp(actor, expectedAppUid) + ) { return { source: 'session-cookie', userUid: actor.user.uuid, @@ -516,7 +546,10 @@ export async function resolvePublicHostedIdentity(opts: { const actor = await authService.authenticateFromToken( bootstrap.token, ); - if (actor?.user?.uuid) { + if ( + actor?.user?.uuid && + actorMatchesExpectedApp(actor, expectedAppUid) + ) { return { source: bootstrap.source, userUid: actor.user.uuid,