diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index d10960095..985ea5b23 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -303,6 +303,18 @@ function appendLinkHeader (res, linkValue) { setHeader(`${existingValue}, ${linkValue}`); } +function setReferrerPolicyHeader (res, policyValue = 'no-referrer') { + const setHeader = typeof res.set === 'function' + ? () => res.set('Referrer-Policy', policyValue) + : ( + typeof res.setHeader === 'function' + ? () => res.setHeader('Referrer-Policy', policyValue) + : null + ); + if ( ! setHeader ) return; + setHeader(); +} + function isPrivateAccessGateEnabled () { return config.enable_private_app_access_gate !== false; } @@ -340,6 +352,31 @@ function stripBootstrapAuthTokenFromOriginalUrl (originalUrl) { } } +function hasAppInstanceIdQueryParam (req) { + const queryParamCandidates = [ + req.query?.['puter.app_instance_id'], + req.query?.puter?.app_instance_id, + ]; + for ( const queryParamCandidate of queryParamCandidates ) { + if ( typeof queryParamCandidate === 'string' && queryParamCandidate.trim() ) { + return true; + } + } + + if ( typeof req.originalUrl !== 'string' || !req.originalUrl ) { + return false; + } + + try { + const placeholderOrigin = 'https://placeholder.puter.local'; + const parsedUrl = new URL(req.originalUrl, placeholderOrigin); + const appInstanceId = parsedUrl.searchParams.get('puter.app_instance_id'); + return typeof appInstanceId === 'string' && !!appInstanceId.trim(); + } catch { + return false; + } +} + function getTokenFromAuthorizationHeader (req) { const authorizationHeader = req.headers?.authorization; if ( typeof authorizationHeader !== 'string' ) return null; @@ -831,6 +868,7 @@ function respondPrivateLoginBootstrap ({ res, app }) { res.status(200); res.set('Cache-Control', 'no-store'); res.set('X-Robots-Tag', 'noindex, nofollow'); + setReferrerPolicyHeader(res); appendLinkHeader( res, marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="canonical"` : null, @@ -929,7 +967,8 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath ); const sanitizedUrl = stripBootstrapAuthTokenFromOriginalUrl(req.originalUrl); - if ( sanitizedUrl ) { + const shouldKeepBootstrapTokenInUrl = hasAppInstanceIdQueryParam(req); + if ( sanitizedUrl && !shouldKeepBootstrapTokenInUrl ) { logPrivateAccessEvent('private_access.allowed_cookie_redirect', { appUid: app.uid, userUid: identity.userUid ?? null, @@ -941,6 +980,16 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath res.redirect(sanitizedUrl); return false; } + if ( sanitizedUrl && shouldKeepBootstrapTokenInUrl ) { + logPrivateAccessEvent('private_access.allowed_cookie_redirect_skipped_for_app_instance', { + appUid: app.uid, + userUid: identity.userUid ?? null, + requestHost: req.hostname, + requestPath, + source: identity.source, + redirectUrl: sanitizedUrl, + }); + } } logPrivateAccessEvent('private_access.allowed', { @@ -1030,6 +1079,10 @@ async function runInternal (req, res, next) { const privateAppEnabled = isPrivateApp(privateApp); const privateAccessGateEnabled = isPrivateAccessGateEnabled(); + if ( privateAppEnabled ) { + setReferrerPolicyHeader(res); + } + if ( privateAccessGateEnabled && privateAppEnabled diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index 0df8ce981..d6c8f7ae7 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -527,6 +527,7 @@ describe('PuterSiteMiddleware', () => { expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()')); expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('localStorage.getItem(\'auth_token\')')); expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('tryStoredTokenBootstrap')); + expect(mockRes.set).toHaveBeenCalledWith('Referrer-Policy', 'no-referrer'); expect(mockRes.redirect).not.toHaveBeenCalled(); expect(mockRes.cookie).not.toHaveBeenCalled(); expect(mockNext).not.toHaveBeenCalled(); @@ -1102,6 +1103,137 @@ describe('PuterSiteMiddleware', () => { expect(mockNext).not.toHaveBeenCalled(); }); + it('does not server-redirect bootstrap token for iframe app instance requests', 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&puter.app_instance_id=instance-111&foo=bar', + cookies: {}, + headers: {}, + query: { + 'puter.auth.token': 'bootstrap-token', + 'puter.app_instance_id': 'instance-111', + foo: 'bar', + }, + 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.createPrivateAssetToken).toHaveBeenCalledWith({ + appUid: 'app-11111111-1111-1111-1111-111111111111', + userUid: 'user-bootstrap-111', + sessionUuid: 'session-bootstrap-111', + subdomain: 'paid', + privateHost: 'paid.puter.dev', + }); + expect(mockRes.cookie).toHaveBeenCalledWith( + 'puter.private.asset.token', + 'private-token', + { sameSite: 'none' }, + ); + expect(mockRes.redirect).not.toHaveBeenCalled(); + expect(filesystemNodeCallCount).toBeGreaterThanOrEqual(2); + 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; diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 52faa4c6b..75ac053d7 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -191,6 +191,18 @@ const puterInit = (function () { // Holds the query parameters found in the current URL let URLParams = new URLSearchParams(globalThis.location?.search); + const normalizeAuthTokenCandidate = (tokenCandidate) => { + if ( typeof tokenCandidate !== 'string' ) return null; + const trimmedTokenCandidate = tokenCandidate.trim(); + if ( + !trimmedTokenCandidate || + trimmedTokenCandidate === 'null' || + trimmedTokenCandidate === 'undefined' + ) { + return null; + } + return trimmedTokenCandidate; + }; // Figure out the environment in which the SDK is running if ( URLParams.has('puter.app_instance_id') ) { @@ -325,17 +337,43 @@ const puterInit = (function () { // Loaded in an iframe in the Puter GUI (i.e. 'app') // When SDK is loaded in App mode the initiation process should start when the DOM is ready else if ( this.env === 'app' ) { - this.authToken = decodeURIComponent(URLParams.get('puter.auth.token')); + const bootstrapAuthToken = normalizeAuthTokenCandidate( + URLParams.get('puter.auth.token') ?? URLParams.get('auth_token'), + ); + this.authToken = bootstrapAuthToken; // initialize submodules this.initSubmodules(); - // If the authToken is already set in localStorage, then we don't need to show the dialog try { - if ( localStorage.getItem('puter.auth.token') ) { - this.setAuthToken(localStorage.getItem('puter.auth.token')); + if ( bootstrapAuthToken ) { + this.setAuthToken(bootstrapAuthToken); + + // Token-in-query is bootstrap-only; persist it then scrub from URL. + if ( globalThis.history?.replaceState && globalThis.location?.href ) { + const currentUrl = new URL(globalThis.location.href); + const hadBootstrapToken = + currentUrl.searchParams.has('puter.auth.token') + || currentUrl.searchParams.has('auth_token'); + if ( hadBootstrapToken ) { + currentUrl.searchParams.delete('puter.auth.token'); + currentUrl.searchParams.delete('auth_token'); + const currentUrlSearch = currentUrl.searchParams.toString(); + const sanitizedRelativeUrl = `${currentUrl.pathname}${currentUrlSearch ? `?${currentUrlSearch}` : ''}${currentUrl.hash || ''}`; + globalThis.history.replaceState(globalThis.history.state, '', sanitizedRelativeUrl); + } + } + } else { + const storedAuthToken = normalizeAuthTokenCandidate( + localStorage.getItem('puter.auth.token'), + ); + // If the authToken is already set in localStorage, then we don't need to show the dialog + if ( storedAuthToken ) { + this.setAuthToken(storedAuthToken); + } } // if appID is already set in localStorage, then we don't need to show the dialog - if ( localStorage.getItem('puter.app.id') ) { - this.setAppID(localStorage.getItem('puter.app.id')); + const storedAppID = localStorage.getItem('puter.app.id'); + if ( storedAppID ) { + this.setAppID(storedAppID); } } catch ( error ) { // Handle the error here