From 7e07c3d937e7233116a568c309c8b72a17e66795 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Fri, 27 Feb 2026 13:55:15 -0800 Subject: [PATCH] feat: add private access rollout gate and auditing (#2560) Adds a config flag to disable private app gate enforcement, structured middleware audit logs for private access decisions, and regression coverage for the disabled-gate path. --- src/backend/src/config.js | 1 + .../routers/hosting/puterSiteMiddleware.js | 67 ++++++++++++- .../hosting/puterSiteMiddleware.test.js | 99 +++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/backend/src/config.js b/src/backend/src/config.js index bb2af7f6c..ccfb60611 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -73,6 +73,7 @@ config.monitor = { config.max_subdomains_per_user = 2000; config.storage_capacity = 1 * 1024 * 1024 * 1024; config.static_hosting_base_domain_redirect = 'https://developer.puter.com/static-hosting/'; +config.enable_private_app_access_gate = true; // Storage limiting is set to false by default // Storage available on the mountpoint/drive puter is running is the storage available diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index f9d3a43ef..e4b9ec395 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -99,6 +99,17 @@ function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) { return '/'; } +function isPrivateAccessGateEnabled () { + return config.enable_private_app_access_gate !== false; +} + +function logPrivateAccessEvent (eventName, fields = {}) { + console.info('private_access', { + eventName, + ...fields, + }); +} + function getTokenFromAuthorizationHeader (req) { const authorizationHeader = req.headers?.authorization; if ( typeof authorizationHeader !== 'string' ) return null; @@ -172,6 +183,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { const authService = services.get('auth'); const privateCookieName = authService.getPrivateAssetCookieName(); const privateCookieToken = req.cookies?.[privateCookieName]; + const hasPrivateCookie = typeof privateCookieToken === 'string' && !!privateCookieToken; + let hasInvalidPrivateCookie = false; if ( typeof privateCookieToken === 'string' && privateCookieToken ) { try { @@ -183,8 +196,11 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { userUid: claims.userUid, sessionUuid: claims.sessionUuid, hasValidPrivateCookie: true, + hasPrivateCookie, + hasInvalidPrivateCookie, }; } catch { + hasInvalidPrivateCookie = true; // fallback to next token source } } @@ -199,6 +215,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { source: 'session-cookie', ...identity, hasValidPrivateCookie: false, + hasPrivateCookie, + hasInvalidPrivateCookie, }; } } catch { @@ -216,6 +234,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { source: 'bootstrap-token', ...identity, hasValidPrivateCookie: false, + hasPrivateCookie, + hasInvalidPrivateCookie, }; } } catch { @@ -228,6 +248,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { userUid: undefined, sessionUuid: undefined, hasValidPrivateCookie: false, + hasPrivateCookie, + hasInvalidPrivateCookie, }; } @@ -252,6 +274,14 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath try { await eventService.emit('app.privateAccess.check', accessCheckEvent); } catch (e) { + logPrivateAccessEvent('private_access.entitlement_check_error', { + appUid: app.uid, + userUid: identity.userUid ?? null, + requestHost: req.hostname, + requestPath, + source: identity.source, + error: e?.message || String(e), + }); console.error('private app access check failed', e); } @@ -260,10 +290,22 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath app, accessCheckEvent.result.redirectUrl, ); + logPrivateAccessEvent('private_access.denied', { + appUid: app.uid, + userUid: identity.userUid ?? null, + requestHost: req.hostname, + requestPath, + source: identity.source, + reason: accessCheckEvent.result.reason ?? null, + redirectUrl, + hasPrivateCookie: identity.hasPrivateCookie, + hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie, + }); res.redirect(redirectUrl); return false; } + const shouldRefreshPrivateCookie = identity.userUid && !identity.hasValidPrivateCookie; if ( identity.userUid && !identity.hasValidPrivateCookie ) { const authService = services.get('auth'); const privateToken = authService.createPrivateAssetToken({ @@ -278,6 +320,16 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath ); } + logPrivateAccessEvent('private_access.allowed', { + appUid: app.uid, + userUid: identity.userUid ?? null, + requestHost: req.hostname, + requestPath, + source: identity.source, + cookieRefreshed: !!shouldRefreshPrivateCookie, + hasPrivateCookie: identity.hasPrivateCookie, + hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie, + }); return true; } @@ -346,10 +398,21 @@ async function runInternal (req, res, next) { ? await get_app({ id: site.associated_app_id }) : null; const privateAppEnabled = isPrivateApp(associatedApp); + const privateAccessGateEnabled = isPrivateAccessGateEnabled(); - if ( privateAppEnabled && !hostMatchesPrivateDomain(req.hostname) ) { + if ( + privateAccessGateEnabled + && privateAppEnabled + && !hostMatchesPrivateDomain(req.hostname) + ) { const privateHostRedirect = buildPrivateHostRedirectUrl(req, associatedApp); if ( privateHostRedirect ) { + logPrivateAccessEvent('private_access.host_redirect', { + appUid: associatedApp?.uid ?? null, + requestHost: req.hostname, + requestPath: req.path, + redirectUrl: privateHostRedirect, + }); return res.redirect(privateHostRedirect); } return res.status(403).send('Private app host mismatch'); @@ -407,7 +470,7 @@ async function runInternal (req, res, next) { req.__puterSiteRootPath = subdomainRootPath; - if ( privateAppEnabled ) { + if ( privateAccessGateEnabled && privateAppEnabled ) { const accessAllowed = await evaluatePrivateAppAccess({ req, res, diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index ad6cc0035..ecb8df5fe 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -33,6 +33,7 @@ vi.mock('../../config.js', () => ({ static_hosting_domain: 'site.puter.localhost', static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/', private_app_hosting_domain: 'puter.app', + enable_private_app_access_gate: true, origin: 'https://puter.com', cookie_name: 'puter.session.token', username_regex: /^[a-z0-9_]+$/, @@ -40,6 +41,7 @@ vi.mock('../../config.js', () => ({ static_hosting_domain: 'site.puter.localhost', static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/', private_app_hosting_domain: 'puter.app', + enable_private_app_access_gate: true, origin: 'https://puter.com', cookie_name: 'puter.session.token', username_regex: /^[a-z0-9_]+$/, @@ -129,6 +131,7 @@ describe('PuterSiteMiddleware', () => { beforeEach(() => { vi.clearAllMocks(); + config.enable_private_app_access_gate = true; Context.get = vi.fn().mockReturnValue(mockContextInstance); getUserMockImpl = async () => null; getAppMockImpl = async () => null; @@ -251,6 +254,7 @@ describe('PuterSiteMiddleware', () => { beforeEach(() => { vi.clearAllMocks(); + config.enable_private_app_access_gate = true; Context.get = vi.fn().mockReturnValue(mockContextInstance); getUserMockImpl = async () => null; getAppMockImpl = async () => null; @@ -405,5 +409,100 @@ describe('PuterSiteMiddleware', () => { expect(mockRes.cookie).not.toHaveBeenCalled(); expect(mockNext).not.toHaveBeenCalled(); }); + + it('skips private app gate when feature flag is disabled', async () => { + config.enable_private_app_access_gate = false; + + const eventEmit = vi.fn(); + 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 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 }; + 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.app/', + }); + + const mockReq = { + hostname: 'paid.site.puter.localhost', + subdomains: ['paid'], + is_custom_domain: false, + baseUrl: '', + path: '/asset.js', + originalUrl: '/asset.js', + query: {}, + cookies: {}, + headers: {}, + on: vi.fn(), + 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(mockRes.redirect).not.toHaveBeenCalled(); + expect(eventEmit).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockNext).not.toHaveBeenCalled(); + }); }); });