diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index 5eae929a5..f32dff525 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -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, diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index d9133f7fc..621e9d14c 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -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(); diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 0fcfda839..a3ac908f0 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -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}`; diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts index d3355f15e..624f0ce9a 100644 --- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -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; + }; + }; }; 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); + }); });