mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-26 19:32:46 +00:00
fix: validate private apps token too (#3124)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user