diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index fe2e73d5f..f3fc06080 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -52,6 +52,7 @@ const rawRequest = ( path: string, headers: Record = {}, method = 'GET', + body?: string, ): Promise => new Promise((resolve, reject) => { const req = http.request( @@ -70,7 +71,7 @@ const rawRequest = ( }, ); req.on('error', reject); - req.end(); + req.end(body); }); /** @@ -519,6 +520,14 @@ describe('PuterServer HTTP alarm gate', () => { }); }) as unknown as RequestHandler, }, + { + method: 'post', + path: '/explode-with-secrets', + options: {}, + handler: (() => { + throw new Error('kaboom'); + }) as unknown as RequestHandler, + }, { method: 'get', path: '/plain', @@ -607,6 +616,37 @@ describe('PuterServer HTTP alarm gate', () => { expect(fields).not.toHaveProperty('details'); }); + it('keeps request body values, query values and the user row out of the alarm', async () => { + const alarm = vi + .spyOn(server.clients.alarm, 'create') + .mockImplementation(() => undefined); + const res = await rawRequest( + port, + '/explode-with-secrets?auth_token=query-secret', + { host: 'puter.localhost', 'content-type': 'application/json' }, + 'POST', + JSON.stringify({ username: 'u', password: 'body-secret' }), + ); + expect(res.status).toBe(500); + const raised = alarm.mock.calls.find((c) => + String(c[0]).startsWith('http_500:POST:/explode-with-secrets:'), + ); + expect(raised).toBeTruthy(); + const [, message, fields] = raised! as unknown as [ + string, + string, + Record, + ]; + const { error: _error, ...rest } = fields; + const serialized = `${message} ${JSON.stringify(rest)}`; + expect(serialized).not.toContain('body-secret'); + expect(serialized).not.toContain('query-secret'); + expect(fields).not.toHaveProperty('body'); + expect(fields.path).toBe('/explode-with-secrets'); + expect(fields.bodyKeys).toEqual(['username', 'password']); + expect(fields.queryKeys).toEqual(['auth_token']); + }); + it('raises a warning when an upstream account is out of credits', async () => { const { id, fields, severity } = await raisedFor( '/credits-exhausted', diff --git a/src/backend/server.ts b/src/backend/server.ts index c2ab43f7a..c2be7be96 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -35,7 +35,7 @@ import { createAuthProbe } from './core/http/middleware/authProbe'; import { createRequestContextMiddleware } from './core/http/middleware/requestContext'; import { createFingerprintMiddleware } from './core/http/middleware/fingerprint'; import { createErrorHandler } from './core/http/middleware/errorHandler'; -import type { Actor } from './core/actor'; +import { actorUid, type Actor } from './core/actor'; import { isHttpError } from './core/http/HttpError'; import { adminOnlyGate, @@ -846,9 +846,14 @@ export class PuterServer { (req as unknown as { route?: { path?: string } }).route ?.path ?? req.path; const alarmId = `http_${status}:${req.method}:${routePath}:${signature}`; + // Alarms leave the process, so carry identifiers only: + // bodies and query strings hold passwords, codes and + // tokens, and the actor carries the whole user row. + const urlPath = req.originalUrl.split('?')[0]; + const body = req.body as unknown; this.clients.alarm.create( alarmId, - `HTTP ${status} on ${req.method} ${req.originalUrl}: ${signature}`, + `HTTP ${status} on ${req.method} ${urlPath}: ${signature}`, { // What the thrower attached (an AI chain's // per-provider attempts, say) rides under one key @@ -861,10 +866,14 @@ export class PuterServer { error: err instanceof Error ? err : undefined, status, method: req.method, - path: req.originalUrl, - body: req.body, + path: urlPath, + queryKeys: Object.keys(req.query ?? {}), + bodyKeys: + body && typeof body === 'object' + ? Object.keys(body) + : [], route: routePath, - actor: req.actor, + actor: req.actor ? actorUid(req.actor) : undefined, }, // An unhandled server error is the one thing that // still pages on-call. diff --git a/src/backend/services/abuse/AppOriginBlocklistService.test.ts b/src/backend/services/abuse/AppOriginBlocklistService.test.ts index fda762db8..d0cc5d3ad 100644 --- a/src/backend/services/abuse/AppOriginBlocklistService.test.ts +++ b/src/backend/services/abuse/AppOriginBlocklistService.test.ts @@ -114,6 +114,26 @@ describe('AppOriginBlocklistService', () => { true, ); }); + + it('treats a fully-qualified trailing dot as the same host', async () => { + const { service } = makeService([ + { domain: 'exact.com', include_subdomains: 0 }, + { domain: 'evil.com.', include_subdomains: 1 }, + ]); + expect((await service.isHostBlocked('exact.com.')).blocked).toBe( + true, + ); + expect( + (await service.isHostBlocked('exact.com.:443')).blocked, + ).toBe(true); + expect((await service.isHostBlocked('a.evil.com')).blocked).toBe( + true, + ); + expect( + (await service.isOriginBlocked('https://a.evil.com./x')) + .blocked, + ).toBe(true); + }); }); describe('isOriginBlocked', () => { diff --git a/src/backend/services/abuse/AppOriginBlocklistService.ts b/src/backend/services/abuse/AppOriginBlocklistService.ts index 2e0c0c5d9..8cf34ab2e 100644 --- a/src/backend/services/abuse/AppOriginBlocklistService.ts +++ b/src/backend/services/abuse/AppOriginBlocklistService.ts @@ -143,7 +143,10 @@ export class AppOriginBlocklistService extends PuterService { } } -/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */ +/** + * Lowercase, trim, drop leading/trailing dots and any port. A trailing dot is + * the fully-qualified spelling of the same host. Returns '' when unusable. + */ const normalizeHost = (host: string): string => { let h = (host ?? '').trim().toLowerCase(); if (!h) return ''; @@ -152,7 +155,7 @@ const normalizeHost = (host: string): string => { // simple split is safe here). const colon = h.indexOf(':'); if (colon !== -1) h = h.slice(0, colon); - return h; + return h.replace(/\.+$/, ''); }; /** Extract the host from a full URL, falling back to treating input as a host. */ diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index e9b6ad6b9..0e2f10dc0 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -2184,6 +2184,56 @@ describe('AuthService (integration)', () => { expect(blocked.actor).toBeUndefined(); expect(blocked.blocked).toBeTruthy(); }); + + it('rejects an already-issued app access token once its origin is blocked', async () => { + const user = await makeUser(); + const host = `late-block-at-${uuidv4()}.example.com`; + const appUid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [appUid, `n-${appUid}`, `t-${appUid}`, `https://${host}/`, 1], + ); + const accessToken = server.services.token.sign( + 'auth', + { + type: 'access-token', + token_uid: uuidv4(), + user_uid: user.uuid, + app_uid: appUid, + }, + { expiresIn: '5m' }, + ); + + const ok = await authService.authenticate(accessToken); + expect(ok.actor?.accessToken?.issuer.app?.uid).toBe(appUid); + + await blockOrigin(host); + const blocked = await authService.authenticate(accessToken); + expect(blocked.actor).toBeUndefined(); + expect(blocked.blocked).toBeTruthy(); + }); + + it('appUidFromOrigin blocks the trailing-dot spelling of a blocked host', async () => { + const host = `blocked-dot-${uuidv4()}.example.com`; + await blockOrigin(host); + await expect( + authService.appUidFromOrigin(`https://${host}./`), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'app_blocked', + }); + }); + + it('appUidFromOrigin resolves the trailing-dot spelling to the same uid', async () => { + const host = `dot-${uuidv4()}.example.com`; + const plain = await authService.appUidFromOrigin( + `https://${host}/`, + ); + const dotted = await authService.appUidFromOrigin( + `https://${host}./`, + ); + expect(dotted).toBe(plain); + }); }); describe('getUserAppToken', () => { diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index dfc1ccb95..091e533c5 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -1094,13 +1094,17 @@ export class AuthService extends PuterService { return this.#normalizedOrigin(parsed); } - /** Scheme + lowercased host + explicit port, with no trailing separator. */ + /** + * Scheme + lowercased host without a trailing dot + explicit port, with no + * trailing separator. + */ #normalizedOrigin(parsed: URL): string { const port = parsed.port ? `:${parsed.port}` : ''; - // `new URL()` lowercases http(s) hosts but leaves opaque ones alone, so - // without this an extension id in two spellings hashes to two app uids - // (and misses the blocklist, which matches on a lowercased host). - return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${port}`; + // `new URL()` lowercases http(s) hosts but leaves opaque ones alone, and + // keeps the fully-qualified trailing dot, so without this one host in + // two spellings hashes to two app uids. + const host = parsed.hostname.toLowerCase().replace(/\.+$/, ''); + return `${parsed.protocol}//${host}${port}`; } /** Same subdomain on the primary hosting domain; null when not hosted. */ @@ -2123,7 +2127,7 @@ export class AuthService extends PuterService { } // Extension schemes aren't "special", so `new URL()` accepts them // with no authority at all (`chrome-extension:`). - if (!parsed.hostname) { + if (!parsed.hostname.replace(/\.+$/, '')) { return null; } return this.#normalizedOrigin(parsed); @@ -2189,20 +2193,8 @@ export class AuthService extends PuterService { const app = await this.stores.app.getByUid(decoded.app_uid); if (!app) return { invalid: true }; - // Reject already-issued app tokens whose app origin is now blocked, so - // a block takes effect immediately rather than waiting for token - // expiry. The app's `index_url` host is the same origin checked at - // token acquisition. - const indexUrl = (app as { index_url?: unknown }).index_url; - if (typeof indexUrl === 'string' && indexUrl) { - const block = - await this.services.appOriginBlocklist.isOriginBlocked( - indexUrl, - ); - if (block.blocked) { - return { blocked: { reason: block.reason } }; - } - } + const blocked = await this.#appOriginBlock(app); + if (blocked) return { blocked }; let rawRow: SessionRow | null = null; if (decoded.session_uid) { @@ -2248,6 +2240,21 @@ export class AuthService extends PuterService { return { actor }; } + /** + * Block details when `app`'s origin is on the blocklist, so tokens the app + * already holds stop working as soon as it's blocked. The `index_url` host + * is the same origin checked at token acquisition. + */ + async #appOriginBlock(app: { + index_url?: unknown; + }): Promise<{ reason?: string } | null> { + const indexUrl = app.index_url; + if (typeof indexUrl !== 'string' || !indexUrl) return null; + const block = + await this.services.appOriginBlocklist.isOriginBlocked(indexUrl); + return block.blocked ? { reason: block.reason } : null; + } + /** * Unix seconds before which an app or worker session for `app` belongs to * an earlier app with the same uid; null when the creation time is @@ -2294,6 +2301,8 @@ export class AuthService extends PuterService { if (decoded.app_uid) { const app = await this.stores.app.getByUid(decoded.app_uid); if (!app) return { invalid: true }; + const blocked = await this.#appOriginBlock(app); + if (blocked) return { blocked }; authorizer = this.#buildAppUnderUserActor(user, app, null); } else { authorizer = this.#buildUserActor(user, null);