mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 00:20:45 +00:00
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
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user