fix: validate private apps token too (#3124)

This commit is contained in:
Daniel Salazar
2026-05-17 12:28:32 -07:00
committed by GitHub
parent a568ca0f99
commit 9e7f38f473
2 changed files with 165 additions and 14 deletions
@@ -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-<hex-uuid>` — 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-<hex-uuid>` 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', () => {
@@ -17,9 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Record<string, unknown>[]>;
}
// ── 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<AppLike | null> {
@@ -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,