fix: bad token generation for private apps (#2596)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-03-04 10:09:16 -08:00
committed by GitHub
parent 6248a89a59
commit 8a14871dde
4 changed files with 327 additions and 8 deletions
@@ -78,7 +78,6 @@ function getPrivateHostingDomainsForMatch () {
for ( const candidate of [
privateAppHostingDomain,
privateAppHostingDomainAlt,
'puter.app',
] ) {
const normalizedCandidate = normalizeConfiguredHostname(candidate);
if ( normalizedCandidate ) {
@@ -450,9 +449,16 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
const actor = await authService.authenticate_from_token(bootstrapToken);
const identity = actorToPrivateIdentity(actor);
if ( identity ) {
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken, {
expectedAppUid: appUid,
});
}
return {
source: 'bootstrap-token',
...identity,
subdomain: privateAppSubdomain,
privateHost: requestedPrivateHost,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
@@ -464,7 +470,9 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
try {
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken);
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken, {
expectedAppUid: appUid,
});
if ( identity ) {
logPrivateAccessEvent('private_access.bootstrap_fallback_allowed', {
appUid,
@@ -824,7 +824,9 @@ describe('PuterSiteMiddleware', () => {
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken)
.toHaveBeenCalledWith('bootstrap-token');
.toHaveBeenCalledWith('bootstrap-token', {
expectedAppUid: 'app-11111111-1111-1111-1111-111111111111',
});
expect(eventEmit).toHaveBeenCalledWith(
'app.privateAccess.check',
expect.objectContaining({
@@ -963,6 +965,139 @@ describe('PuterSiteMiddleware', () => {
expect(mockNext).not.toHaveBeenCalled();
});
it('includes subdomain and private host when strict bootstrap token auth succeeds', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = true;
});
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-bootstrap-111' },
session: 'session-bootstrap-111',
},
}),
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-bootstrap-111',
sessionUuid: 'session-bootstrap-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js?puter.auth.token=bootstrap-token',
cookies: {},
headers: {},
query: {
'puter.auth.token': 'bootstrap-token',
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken).toHaveBeenCalledWith('bootstrap-token', {
expectedAppUid: 'app-11111111-1111-1111-1111-111111111111',
});
expect(authService.createPrivateAssetToken).toHaveBeenCalledWith({
appUid: 'app-11111111-1111-1111-1111-111111111111',
userUid: 'user-bootstrap-111',
sessionUuid: 'session-bootstrap-111',
subdomain: 'paid',
privateHost: 'paid.puter.dev',
});
expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
requestHostname: 'paid.puter.dev',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.private.asset.token',
'private-token',
{ sameSite: 'none' },
);
expect(mockNext).not.toHaveBeenCalled();
});
it('accepts nested query token key for bootstrap auth', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
@@ -1059,7 +1194,9 @@ describe('PuterSiteMiddleware', () => {
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken)
.toHaveBeenCalledWith('bootstrap-token');
.toHaveBeenCalledWith('bootstrap-token', {
expectedAppUid: 'app-11111111-1111-1111-1111-111111111111',
});
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
expect(mockRes.send).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
+109 -4
View File
@@ -527,7 +527,7 @@ class AuthService extends BaseService {
return null;
}
async resolvePrivateBootstrapIdentityFromToken (token) {
async resolvePrivateBootstrapIdentityFromToken (token, { expectedAppUid } = {}) {
let decoded;
try {
decoded = this.tokenService.verify('auth', token);
@@ -546,6 +546,12 @@ class AuthService extends BaseService {
if ( ! allowedTypes.has(decoded.type) ) {
throw APIError.create('token_auth_failed');
}
const bootstrapAppUid = typeof decoded?.app_uid === 'string'
? decoded.app_uid
: null;
if ( expectedAppUid && bootstrapAppUid && bootstrapAppUid !== expectedAppUid ) {
throw APIError.create('token_auth_failed');
}
const sessionUuid = this.resolvePrivateBootstrapSessionUuid(decoded);
if ( ! sessionUuid ) {
@@ -980,10 +986,109 @@ class AuthService extends BaseService {
return await this._app_uid_from_origin(origin);
}
normalizeHostedDomainCandidate (domainValue) {
if ( typeof domainValue !== 'string' ) return null;
const normalizedDomainValue = domainValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedDomainValue ) return null;
try {
const parsedDomain = new URL(`http://${normalizedDomainValue}`);
return {
host: parsedDomain.host.toLowerCase(),
hostname: parsedDomain.hostname.toLowerCase(),
};
} catch {
const [hostname] = normalizedDomainValue.split(':');
if ( ! hostname ) return null;
return {
host: normalizedDomainValue,
hostname,
};
}
}
getHostedAppDomainCandidatesForMatch () {
const hostedDomainCandidates = [];
const seenHostnames = new Set();
for ( const domainCandidate of [
this.global_config.static_hosting_domain,
this.global_config.static_hosting_domain_alt,
this.global_config.private_app_hosting_domain,
this.global_config.private_app_hosting_domain_alt,
] ) {
const normalizedDomainCandidate = this.normalizeHostedDomainCandidate(domainCandidate);
if ( ! normalizedDomainCandidate ) continue;
if ( seenHostnames.has(normalizedDomainCandidate.hostname) ) continue;
seenHostnames.add(normalizedDomainCandidate.hostname);
hostedDomainCandidates.push(normalizedDomainCandidate);
}
return hostedDomainCandidates;
}
getCanonicalHostedAppDomain () {
for ( const domainCandidate of [
this.global_config.static_hosting_domain,
this.global_config.static_hosting_domain_alt,
this.global_config.private_app_hosting_domain,
this.global_config.private_app_hosting_domain_alt,
] ) {
const normalizedDomainCandidate = this.normalizeHostedDomainCandidate(domainCandidate);
if ( normalizedDomainCandidate?.host ) {
return normalizedDomainCandidate.host;
}
}
return null;
}
extractHostedAppSubdomainFromHostname (hostname) {
if ( typeof hostname !== 'string' ) return null;
const normalizedHostname = hostname.trim().toLowerCase();
if ( ! normalizedHostname ) return null;
const hostedDomainCandidates = this.getHostedAppDomainCandidatesForMatch()
.sort((domainCandidateA, domainCandidateB) =>
domainCandidateB.hostname.length - domainCandidateA.hostname.length);
for ( const hostedDomainCandidate of hostedDomainCandidates ) {
if ( normalizedHostname === hostedDomainCandidate.hostname ) {
return null;
}
const hostedDomainSuffix = `.${hostedDomainCandidate.hostname}`;
if ( normalizedHostname.endsWith(hostedDomainSuffix) ) {
const subdomain = normalizedHostname.slice(
0,
normalizedHostname.length - hostedDomainSuffix.length,
);
return subdomain || null;
}
}
return null;
}
canonicalizeHostedAppOriginForUid (origin) {
try {
const parsedOrigin = new URL(origin);
const hostedSubdomain = this.extractHostedAppSubdomainFromHostname(parsedOrigin.hostname);
if ( ! hostedSubdomain ) return origin;
const canonicalHostedDomain = this.getCanonicalHostedAppDomain();
if ( ! canonicalHostedDomain ) return origin;
return `${parsedOrigin.protocol}//${hostedSubdomain}.${canonicalHostedDomain}`;
} catch {
return origin;
}
}
async _app_uid_from_origin (origin) {
const event = { origin };
const svc_event = this.services.get('event');
await svc_event.emit('app.from-origin', event);
const canonicalOrigin = this.canonicalizeHostedAppOriginForUid(origin);
const event = { origin: canonicalOrigin };
const eventService = this.services.get('event');
await eventService.emit('app.from-origin', event);
// UUIDV5
const uuid = uuidLib.v5(event.origin, APP_ORIGIN_UUID_NAMESPACE);
return `app-${uuid}`;
@@ -7,6 +7,8 @@ type AuthServiceForPrivateTokenTests = AuthService & {
jwt_secret: string;
private_app_asset_token_ttl_seconds: number;
private_app_asset_cookie_name: string;
static_hosting_domain: string;
static_hosting_domain_alt?: string;
private_app_hosting_domain: string;
private_app_hosting_domain_alt?: string;
};
@@ -24,6 +26,11 @@ type AuthServiceForPrivateTokenTests = AuthService & {
encrypt: (value: string) => string;
decrypt: (value: string) => string;
};
services: {
get: (name: string) => {
emit?: (eventName: string, event: unknown) => Promise<void>;
};
};
};
const createAuthService = (): AuthServiceForPrivateTokenTests => {
@@ -32,6 +39,8 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
jwt_secret: 'private-asset-test-secret',
private_app_asset_token_ttl_seconds: 3600,
private_app_asset_cookie_name: 'puter.private.asset.token',
static_hosting_domain: 'puter.site',
static_hosting_domain_alt: 'puter.host',
private_app_hosting_domain: 'app.puter.localhost',
private_app_hosting_domain_alt: 'puter.dev',
};
@@ -51,6 +60,12 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
encrypt: (value) => value,
decrypt: (value) => value,
};
authService.services = {
get: (_name) => ({
emit: async () => {
},
}),
};
// @ts-expect-error test-only lightweight stub
authService.get_session_ = vi.fn().mockResolvedValue(undefined);
return authService;
@@ -235,6 +250,30 @@ describe('AuthService private asset token helpers', () => {
.toThrow();
});
it('rejects bootstrap identity when expected app uid does not match token app uid', async () => {
const authService = createAuthService();
const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0';
const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7';
const token = jwt.sign({
type: 'app-under-user',
version: '0.0.0',
user_uid: userUid,
app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683',
session: sessionUuid,
}, authService.global_config.jwt_secret, { expiresIn: 60 });
authService.get_session_ = vi.fn().mockResolvedValue({
uuid: sessionUuid,
user_uid: userUid,
});
await expect(authService.resolvePrivateBootstrapIdentityFromToken(token, {
expectedAppUid: 'app-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
}))
.rejects
.toThrow();
});
it('rejects bootstrap identity token when signature is tampered', async () => {
const authService = createAuthService();
const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0';
@@ -252,4 +291,34 @@ describe('AuthService private asset token helpers', () => {
.rejects
.toThrow();
});
it('derives same app uid for hosted app domain aliases', async () => {
const authService = createAuthService();
authService.global_config.static_hosting_domain = 'puter.site';
authService.global_config.static_hosting_domain_alt = 'puter.host';
authService.global_config.private_app_hosting_domain = 'puter.app';
authService.global_config.private_app_hosting_domain_alt = 'puter.dev';
const uidSite = await authService.app_uid_from_origin('https://beans.puter.site');
const uidStaticAlt = await authService.app_uid_from_origin('https://beans.puter.host');
const uidPrivatePrimary = await authService.app_uid_from_origin('https://beans.puter.app');
const uidPrivateAlt = await authService.app_uid_from_origin('https://beans.puter.dev');
expect(uidSite).toBe(uidStaticAlt);
expect(uidSite).toBe(uidPrivatePrimary);
expect(uidSite).toBe(uidPrivateAlt);
});
it('keeps distinct app uid per subdomain under hosted alias canonicalization', async () => {
const authService = createAuthService();
authService.global_config.static_hosting_domain = 'puter.site';
authService.global_config.static_hosting_domain_alt = 'puter.host';
authService.global_config.private_app_hosting_domain = 'puter.app';
authService.global_config.private_app_hosting_domain_alt = 'puter.dev';
const uidBeans = await authService.app_uid_from_origin('https://beans.puter.dev');
const uidCats = await authService.app_uid_from_origin('https://cats.puter.site');
expect(uidBeans).not.toBe(uidCats);
});
});