mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 16:40:41 +00:00
fix: bad token generation for private apps (#2596)
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:
@@ -78,7 +78,6 @@ function getPrivateHostingDomainsForMatch () {
|
||||
for ( const candidate of [
|
||||
privateAppHostingDomain,
|
||||
privateAppHostingDomainAlt,
|
||||
'puter.app',
|
||||
] ) {
|
||||
const normalizedCandidate = normalizeConfiguredHostname(candidate);
|
||||
if ( normalizedCandidate ) {
|
||||
@@ -450,9 +449,16 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
|
||||
const actor = await authService.authenticate_from_token(bootstrapToken);
|
||||
const identity = actorToPrivateIdentity(actor);
|
||||
if ( identity ) {
|
||||
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
|
||||
await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken, {
|
||||
expectedAppUid: appUid,
|
||||
});
|
||||
}
|
||||
return {
|
||||
source: 'bootstrap-token',
|
||||
...identity,
|
||||
subdomain: privateAppSubdomain,
|
||||
privateHost: requestedPrivateHost,
|
||||
hasValidPrivateCookie: false,
|
||||
hasPrivateCookie,
|
||||
hasInvalidPrivateCookie,
|
||||
@@ -464,7 +470,9 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
|
||||
|
||||
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
|
||||
try {
|
||||
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken);
|
||||
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken, {
|
||||
expectedAppUid: appUid,
|
||||
});
|
||||
if ( identity ) {
|
||||
logPrivateAccessEvent('private_access.bootstrap_fallback_allowed', {
|
||||
appUid,
|
||||
|
||||
@@ -824,7 +824,9 @@ describe('PuterSiteMiddleware', () => {
|
||||
|
||||
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
|
||||
expect(authService.resolvePrivateBootstrapIdentityFromToken)
|
||||
.toHaveBeenCalledWith('bootstrap-token');
|
||||
.toHaveBeenCalledWith('bootstrap-token', {
|
||||
expectedAppUid: 'app-11111111-1111-1111-1111-111111111111',
|
||||
});
|
||||
expect(eventEmit).toHaveBeenCalledWith(
|
||||
'app.privateAccess.check',
|
||||
expect.objectContaining({
|
||||
@@ -963,6 +965,139 @@ describe('PuterSiteMiddleware', () => {
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes subdomain and private host when strict bootstrap token auth succeeds', 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',
|
||||
cookies: {},
|
||||
headers: {},
|
||||
query: {
|
||||
'puter.auth.token': 'bootstrap-token',
|
||||
},
|
||||
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.resolvePrivateBootstrapIdentityFromToken).toHaveBeenCalledWith('bootstrap-token', {
|
||||
expectedAppUid: 'app-11111111-1111-1111-1111-111111111111',
|
||||
});
|
||||
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(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
|
||||
requestHostname: 'paid.puter.dev',
|
||||
});
|
||||
expect(mockRes.cookie).toHaveBeenCalledWith(
|
||||
'puter.private.asset.token',
|
||||
'private-token',
|
||||
{ sameSite: 'none' },
|
||||
);
|
||||
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;
|
||||
@@ -1059,7 +1194,9 @@ describe('PuterSiteMiddleware', () => {
|
||||
|
||||
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
|
||||
expect(authService.resolvePrivateBootstrapIdentityFromToken)
|
||||
.toHaveBeenCalledWith('bootstrap-token');
|
||||
.toHaveBeenCalledWith('bootstrap-token', {
|
||||
expectedAppUid: 'app-11111111-1111-1111-1111-111111111111',
|
||||
});
|
||||
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
|
||||
expect(mockRes.send).not.toHaveBeenCalled();
|
||||
expect(mockRes.cookie).not.toHaveBeenCalled();
|
||||
|
||||
@@ -527,7 +527,7 @@ class AuthService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async resolvePrivateBootstrapIdentityFromToken (token) {
|
||||
async resolvePrivateBootstrapIdentityFromToken (token, { expectedAppUid } = {}) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.tokenService.verify('auth', token);
|
||||
@@ -546,6 +546,12 @@ class AuthService extends BaseService {
|
||||
if ( ! allowedTypes.has(decoded.type) ) {
|
||||
throw APIError.create('token_auth_failed');
|
||||
}
|
||||
const bootstrapAppUid = typeof decoded?.app_uid === 'string'
|
||||
? decoded.app_uid
|
||||
: null;
|
||||
if ( expectedAppUid && bootstrapAppUid && bootstrapAppUid !== expectedAppUid ) {
|
||||
throw APIError.create('token_auth_failed');
|
||||
}
|
||||
|
||||
const sessionUuid = this.resolvePrivateBootstrapSessionUuid(decoded);
|
||||
if ( ! sessionUuid ) {
|
||||
@@ -980,10 +986,109 @@ class AuthService extends BaseService {
|
||||
return await this._app_uid_from_origin(origin);
|
||||
}
|
||||
|
||||
normalizeHostedDomainCandidate (domainValue) {
|
||||
if ( typeof domainValue !== 'string' ) return null;
|
||||
|
||||
const normalizedDomainValue = domainValue.trim().toLowerCase().replace(/^\./, '');
|
||||
if ( ! normalizedDomainValue ) return null;
|
||||
|
||||
try {
|
||||
const parsedDomain = new URL(`http://${normalizedDomainValue}`);
|
||||
return {
|
||||
host: parsedDomain.host.toLowerCase(),
|
||||
hostname: parsedDomain.hostname.toLowerCase(),
|
||||
};
|
||||
} catch {
|
||||
const [hostname] = normalizedDomainValue.split(':');
|
||||
if ( ! hostname ) return null;
|
||||
return {
|
||||
host: normalizedDomainValue,
|
||||
hostname,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getHostedAppDomainCandidatesForMatch () {
|
||||
const hostedDomainCandidates = [];
|
||||
const seenHostnames = new Set();
|
||||
|
||||
for ( const domainCandidate of [
|
||||
this.global_config.static_hosting_domain,
|
||||
this.global_config.static_hosting_domain_alt,
|
||||
this.global_config.private_app_hosting_domain,
|
||||
this.global_config.private_app_hosting_domain_alt,
|
||||
] ) {
|
||||
const normalizedDomainCandidate = this.normalizeHostedDomainCandidate(domainCandidate);
|
||||
if ( ! normalizedDomainCandidate ) continue;
|
||||
if ( seenHostnames.has(normalizedDomainCandidate.hostname) ) continue;
|
||||
seenHostnames.add(normalizedDomainCandidate.hostname);
|
||||
hostedDomainCandidates.push(normalizedDomainCandidate);
|
||||
}
|
||||
|
||||
return hostedDomainCandidates;
|
||||
}
|
||||
|
||||
getCanonicalHostedAppDomain () {
|
||||
for ( const domainCandidate of [
|
||||
this.global_config.static_hosting_domain,
|
||||
this.global_config.static_hosting_domain_alt,
|
||||
this.global_config.private_app_hosting_domain,
|
||||
this.global_config.private_app_hosting_domain_alt,
|
||||
] ) {
|
||||
const normalizedDomainCandidate = this.normalizeHostedDomainCandidate(domainCandidate);
|
||||
if ( normalizedDomainCandidate?.host ) {
|
||||
return normalizedDomainCandidate.host;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
extractHostedAppSubdomainFromHostname (hostname) {
|
||||
if ( typeof hostname !== 'string' ) return null;
|
||||
const normalizedHostname = hostname.trim().toLowerCase();
|
||||
if ( ! normalizedHostname ) return null;
|
||||
|
||||
const hostedDomainCandidates = this.getHostedAppDomainCandidatesForMatch()
|
||||
.sort((domainCandidateA, domainCandidateB) =>
|
||||
domainCandidateB.hostname.length - domainCandidateA.hostname.length);
|
||||
|
||||
for ( const hostedDomainCandidate of hostedDomainCandidates ) {
|
||||
if ( normalizedHostname === hostedDomainCandidate.hostname ) {
|
||||
return null;
|
||||
}
|
||||
const hostedDomainSuffix = `.${hostedDomainCandidate.hostname}`;
|
||||
if ( normalizedHostname.endsWith(hostedDomainSuffix) ) {
|
||||
const subdomain = normalizedHostname.slice(
|
||||
0,
|
||||
normalizedHostname.length - hostedDomainSuffix.length,
|
||||
);
|
||||
return subdomain || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
canonicalizeHostedAppOriginForUid (origin) {
|
||||
try {
|
||||
const parsedOrigin = new URL(origin);
|
||||
const hostedSubdomain = this.extractHostedAppSubdomainFromHostname(parsedOrigin.hostname);
|
||||
if ( ! hostedSubdomain ) return origin;
|
||||
|
||||
const canonicalHostedDomain = this.getCanonicalHostedAppDomain();
|
||||
if ( ! canonicalHostedDomain ) return origin;
|
||||
|
||||
return `${parsedOrigin.protocol}//${hostedSubdomain}.${canonicalHostedDomain}`;
|
||||
} catch {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
|
||||
async _app_uid_from_origin (origin) {
|
||||
const event = { origin };
|
||||
const svc_event = this.services.get('event');
|
||||
await svc_event.emit('app.from-origin', event);
|
||||
const canonicalOrigin = this.canonicalizeHostedAppOriginForUid(origin);
|
||||
const event = { origin: canonicalOrigin };
|
||||
const eventService = this.services.get('event');
|
||||
await eventService.emit('app.from-origin', event);
|
||||
// UUIDV5
|
||||
const uuid = uuidLib.v5(event.origin, APP_ORIGIN_UUID_NAMESPACE);
|
||||
return `app-${uuid}`;
|
||||
|
||||
@@ -7,6 +7,8 @@ type AuthServiceForPrivateTokenTests = AuthService & {
|
||||
jwt_secret: string;
|
||||
private_app_asset_token_ttl_seconds: number;
|
||||
private_app_asset_cookie_name: string;
|
||||
static_hosting_domain: string;
|
||||
static_hosting_domain_alt?: string;
|
||||
private_app_hosting_domain: string;
|
||||
private_app_hosting_domain_alt?: string;
|
||||
};
|
||||
@@ -24,6 +26,11 @@ type AuthServiceForPrivateTokenTests = AuthService & {
|
||||
encrypt: (value: string) => string;
|
||||
decrypt: (value: string) => string;
|
||||
};
|
||||
services: {
|
||||
get: (name: string) => {
|
||||
emit?: (eventName: string, event: unknown) => Promise<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const createAuthService = (): AuthServiceForPrivateTokenTests => {
|
||||
@@ -32,6 +39,8 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
|
||||
jwt_secret: 'private-asset-test-secret',
|
||||
private_app_asset_token_ttl_seconds: 3600,
|
||||
private_app_asset_cookie_name: 'puter.private.asset.token',
|
||||
static_hosting_domain: 'puter.site',
|
||||
static_hosting_domain_alt: 'puter.host',
|
||||
private_app_hosting_domain: 'app.puter.localhost',
|
||||
private_app_hosting_domain_alt: 'puter.dev',
|
||||
};
|
||||
@@ -51,6 +60,12 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
|
||||
encrypt: (value) => value,
|
||||
decrypt: (value) => value,
|
||||
};
|
||||
authService.services = {
|
||||
get: (_name) => ({
|
||||
emit: async () => {
|
||||
},
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error test-only lightweight stub
|
||||
authService.get_session_ = vi.fn().mockResolvedValue(undefined);
|
||||
return authService;
|
||||
@@ -235,6 +250,30 @@ describe('AuthService private asset token helpers', () => {
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('rejects bootstrap identity when expected app uid does not match token app uid', async () => {
|
||||
const authService = createAuthService();
|
||||
const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0';
|
||||
const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7';
|
||||
const token = jwt.sign({
|
||||
type: 'app-under-user',
|
||||
version: '0.0.0',
|
||||
user_uid: userUid,
|
||||
app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683',
|
||||
session: sessionUuid,
|
||||
}, authService.global_config.jwt_secret, { expiresIn: 60 });
|
||||
|
||||
authService.get_session_ = vi.fn().mockResolvedValue({
|
||||
uuid: sessionUuid,
|
||||
user_uid: userUid,
|
||||
});
|
||||
|
||||
await expect(authService.resolvePrivateBootstrapIdentityFromToken(token, {
|
||||
expectedAppUid: 'app-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
}))
|
||||
.rejects
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('rejects bootstrap identity token when signature is tampered', async () => {
|
||||
const authService = createAuthService();
|
||||
const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0';
|
||||
@@ -252,4 +291,34 @@ describe('AuthService private asset token helpers', () => {
|
||||
.rejects
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('derives same app uid for hosted app domain aliases', async () => {
|
||||
const authService = createAuthService();
|
||||
authService.global_config.static_hosting_domain = 'puter.site';
|
||||
authService.global_config.static_hosting_domain_alt = 'puter.host';
|
||||
authService.global_config.private_app_hosting_domain = 'puter.app';
|
||||
authService.global_config.private_app_hosting_domain_alt = 'puter.dev';
|
||||
|
||||
const uidSite = await authService.app_uid_from_origin('https://beans.puter.site');
|
||||
const uidStaticAlt = await authService.app_uid_from_origin('https://beans.puter.host');
|
||||
const uidPrivatePrimary = await authService.app_uid_from_origin('https://beans.puter.app');
|
||||
const uidPrivateAlt = await authService.app_uid_from_origin('https://beans.puter.dev');
|
||||
|
||||
expect(uidSite).toBe(uidStaticAlt);
|
||||
expect(uidSite).toBe(uidPrivatePrimary);
|
||||
expect(uidSite).toBe(uidPrivateAlt);
|
||||
});
|
||||
|
||||
it('keeps distinct app uid per subdomain under hosted alias canonicalization', async () => {
|
||||
const authService = createAuthService();
|
||||
authService.global_config.static_hosting_domain = 'puter.site';
|
||||
authService.global_config.static_hosting_domain_alt = 'puter.host';
|
||||
authService.global_config.private_app_hosting_domain = 'puter.app';
|
||||
authService.global_config.private_app_hosting_domain_alt = 'puter.dev';
|
||||
|
||||
const uidBeans = await authService.app_uid_from_origin('https://beans.puter.dev');
|
||||
const uidCats = await authService.app_uid_from_origin('https://cats.puter.site');
|
||||
|
||||
expect(uidBeans).not.toBe(uidCats);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user