fix: private app token (#2623)
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-09 12:49:14 -07:00
committed by GitHub
parent 5e7c2c3ddd
commit e4a52947fe
3 changed files with 230 additions and 7 deletions
@@ -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
@@ -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;
+44 -6
View File
@@ -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