diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index 988a3a395..5eae929a5 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -127,6 +127,13 @@ function getSubdomainFromHostedRequest (req) { return host.split('.')[0] || ''; } +function getRequestedPrivateHost (req) { + const normalizedRequestHost = normalizeConfiguredHostname(req.hostname); + if ( ! normalizedRequestHost ) return undefined; + if ( ! hostMatchesPrivateDomain(normalizedRequestHost) ) return undefined; + return normalizedRequestHost; +} + function buildPrivateHostRedirectUrl (req, app) { if ( ! app ) { return null; @@ -388,6 +395,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { const privateCookieName = authService.getPrivateAssetCookieName(); const privateCookieToken = req.cookies?.[privateCookieName]; const privateAppSubdomain = getSubdomainFromHostedRequest(req) || undefined; + const requestedPrivateHost = getRequestedPrivateHost(req); const hasPrivateCookie = typeof privateCookieToken === 'string' && !!privateCookieToken; let hasInvalidPrivateCookie = false; @@ -396,12 +404,14 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { const claims = authService.verifyPrivateAssetToken(privateCookieToken, { expectedAppUid: appUid, expectedSubdomain: privateAppSubdomain, + expectedPrivateHost: requestedPrivateHost, }); return { source: 'private-cookie', userUid: claims.userUid, sessionUuid: claims.sessionUuid, subdomain: claims.subdomain || privateAppSubdomain, + privateHost: claims.privateHost || requestedPrivateHost, hasValidPrivateCookie: true, hasPrivateCookie, hasInvalidPrivateCookie, @@ -422,6 +432,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { source: 'session-cookie', ...identity, subdomain: privateAppSubdomain, + privateHost: requestedPrivateHost, hasValidPrivateCookie: false, hasPrivateCookie, hasInvalidPrivateCookie, @@ -465,6 +476,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { source: 'bootstrap-token', ...identity, subdomain: privateAppSubdomain, + privateHost: requestedPrivateHost, hasValidPrivateCookie: false, hasPrivateCookie, hasInvalidPrivateCookie, @@ -487,6 +499,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { userUid: undefined, sessionUuid: undefined, subdomain: privateAppSubdomain, + privateHost: requestedPrivateHost, hasValidPrivateCookie: false, hasPrivateCookie, hasInvalidPrivateCookie, @@ -786,6 +799,7 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath userUid: identity.userUid, sessionUuid: identity.sessionUuid, subdomain: identity.subdomain, + privateHost: identity.privateHost, }); res.cookie( authService.getPrivateAssetCookieName(), diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index 2796a00fb..d9133f7fc 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -953,6 +953,7 @@ describe('PuterSiteMiddleware', () => { userUid: 'user-allow-111', sessionUuid: 'session-allow-111', subdomain: 'paid', + privateHost: 'paid.puter.dev', }); expect(mockRes.cookie).toHaveBeenCalledWith( 'puter.private.asset.token', diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 81e5fdadd..0fcfda839 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -365,7 +365,14 @@ class AuthService extends BaseService { return normalizedSubdomain || undefined; } - createPrivateAssetToken ({ appUid, userUid, sessionUuid, subdomain, ttlSeconds } = {}) { + normalizePrivateAssetHost (privateHost) { + if ( typeof privateHost !== 'string' ) return undefined; + const normalizedPrivateHost = privateHost.trim().toLowerCase().replace(/^\./, ''); + if ( ! normalizedPrivateHost ) return undefined; + return normalizedPrivateHost; + } + + createPrivateAssetToken ({ appUid, userUid, sessionUuid, subdomain, privateHost, ttlSeconds } = {}) { if ( typeof appUid !== 'string' || !appUid.trim() ) { throw new Error('appUid is required to create private asset token.'); } @@ -379,6 +386,10 @@ class AuthService extends BaseService { if ( subdomain !== undefined && !normalizedSubdomain ) { throw new Error('subdomain must be a non-empty string when provided.'); } + const normalizedPrivateHost = this.normalizePrivateAssetHost(privateHost); + if ( privateHost !== undefined && !normalizedPrivateHost ) { + throw new Error('privateHost must be a non-empty string when provided.'); + } const effectiveTtlSeconds = this.resolvePositiveInteger( ttlSeconds, @@ -392,6 +403,7 @@ class AuthService extends BaseService { user_uid: userUid.trim(), ...(sessionUuid ? { session: this.uuid_fpe.encrypt(sessionUuid) } : {}), ...(normalizedSubdomain ? { subdomain: normalizedSubdomain } : {}), + ...(normalizedPrivateHost ? { private_host: normalizedPrivateHost } : {}), }; return this.tokenService.sign('auth', payload, { @@ -401,7 +413,7 @@ class AuthService extends BaseService { verifyPrivateAssetToken ( token, - { expectedAppUid, expectedUserUid, expectedSessionUuid, expectedSubdomain } = {}, + { expectedAppUid, expectedUserUid, expectedSessionUuid, expectedSubdomain, expectedPrivateHost } = {}, ) { let decoded; try { @@ -440,6 +452,13 @@ class AuthService extends BaseService { } subdomain = decoded.subdomain.trim().toLowerCase(); } + let privateHost; + if ( decoded.private_host !== undefined ) { + if ( typeof decoded.private_host !== 'string' || !decoded.private_host.trim() ) { + throw APIError.create('token_auth_failed'); + } + privateHost = decoded.private_host.trim().toLowerCase(); + } if ( expectedAppUid && decoded.app_uid !== expectedAppUid ) { throw APIError.create('token_auth_failed'); @@ -461,12 +480,22 @@ class AuthService extends BaseService { throw APIError.create('token_auth_failed'); } } + const normalizedExpectedPrivateHost = this.normalizePrivateAssetHost(expectedPrivateHost); + if ( expectedPrivateHost !== undefined && !normalizedExpectedPrivateHost ) { + throw APIError.create('token_auth_failed'); + } + if ( normalizedExpectedPrivateHost ) { + if ( !privateHost || privateHost !== normalizedExpectedPrivateHost ) { + throw APIError.create('token_auth_failed'); + } + } return { appUid: decoded.app_uid, userUid: decoded.user_uid, sessionUuid, subdomain, + privateHost, exp: decoded.exp, iat: decoded.iat, }; diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts index 14aa4d353..d3355f15e 100644 --- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -77,12 +77,14 @@ describe('AuthService private asset token helpers', () => { const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; const subdomain = 'beans'; + const privateHost = 'beans.puter.dev'; const token = authService.createPrivateAssetToken({ appUid, userUid, sessionUuid, subdomain, + privateHost, ttlSeconds: 120, }); @@ -91,12 +93,14 @@ describe('AuthService private asset token helpers', () => { expectedUserUid: userUid, expectedSessionUuid: sessionUuid, expectedSubdomain: subdomain, + expectedPrivateHost: privateHost, }); expect(claims.appUid).toBe(appUid); expect(claims.userUid).toBe(userUid); expect(claims.sessionUuid).toBe(sessionUuid); expect(claims.subdomain).toBe(subdomain); + expect(claims.privateHost).toBe(privateHost); expect(typeof claims.exp).toBe('number'); }); @@ -106,6 +110,7 @@ describe('AuthService private asset token helpers', () => { appUid: 'app-9f1c10e3-9a7f-43fb-8671-af4918e65407', userUid: '9885b80e-1a14-4c8d-9e3f-4fa5915b1136', subdomain: 'beans', + privateHost: 'beans.puter.dev', }); expect(() => authService.verifyPrivateAssetToken(token, { @@ -119,6 +124,10 @@ describe('AuthService private asset token helpers', () => { expect(() => authService.verifyPrivateAssetToken(token, { expectedSubdomain: 'other-app', })).toThrow(); + + expect(() => authService.verifyPrivateAssetToken(token, { + expectedPrivateHost: 'other.puter.dev', + })).toThrow(); }); it('rejects non private-asset tokens', () => {