diff --git a/package-lock.json b/package-lock.json index 361c8ee9f..0b00f59b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15207,7 +15207,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -16242,7 +16244,6 @@ }, "node_modules/undici": { "version": "7.25.0", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -17619,6 +17620,7 @@ "together-ai": "^0.33.0", "ua-parser-js": "^1.0.41", "uglify-js": "^3.17.4", + "undici": "^7.25.0", "uuid": "^14.0.0", "validator": "^13.15.35" }, @@ -18597,8 +18599,8 @@ }, "src/puter-js": { "name": "@heyputer/puter.js", - "version": "2.5.1", - "license": "AGPL-3.0-only", + "version": "2.5.3", + "license": "Apache-2.0", "dependencies": { "@heyputer/kv.js": "^0.2.1", "open": "^10.2.0" diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 419640b7e..5f521b833 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -1685,6 +1685,43 @@ describe('AuthController password recovery', () => { ), ).rejects.toMatchObject({ statusCode: 400 }); }); + + it('set-pass-using-token: revokes all of the user’s interactive sessions', async () => { + // A password reset is the "someone may have my account" flow, so + // existing sessions must not survive it. + const { user } = await makeUserAndActor(); + const recoveryToken = uuidv4(); + await server.stores.user.update(user.id, { + pass_recovery_token: recoveryToken, + }); + const jwt = server.services.token.sign( + 'otp', + { + token: recoveryToken, + user_uid: user.uuid, + email: user.email, + purpose: 'pass-recovery', + }, + { expiresIn: '1h' }, + ); + + const s1 = await server.services.auth.createSessionToken(user, {}); + const s2 = await server.services.auth.createSessionToken(user, {}); + const uuid1 = (s1.session as { uuid: string }).uuid; + const uuid2 = (s2.session as { uuid: string }).uuid; + // Both are live before the reset. + expect(await server.stores.session.getByUuid(uuid1)).not.toBeNull(); + expect(await server.stores.session.getByUuid(uuid2)).not.toBeNull(); + + await controller.handleSetPassUsingToken( + makeReq({ token: jwt, password: 'a-brand-new-password' }), + makeRes(), + ); + + // Every interactive session is revoked (getByUuid gates on revoked_at). + expect(await server.stores.session.getByUuid(uuid1)).toBeNull(); + expect(await server.stores.session.getByUuid(uuid2)).toBeNull(); + }); }); // ── User-protected change-* (skipping middleware-driven setup) ───── @@ -1731,6 +1768,34 @@ describe('AuthController user-protected mutations (validation paths)', () => { ).toBe(true); }); + it('change-password: revokes the user’s other web sessions but keeps the current one', async () => { + const { user, actor } = await makeUserAndActor(); + // Two web sessions for this user; one is the session performing the + // change (the "current" one, identified via actor.session.uid). + const current = await server.services.auth.createSessionToken(user, {}); + const other = await server.services.auth.createSessionToken(user, {}); + const currentUuid = (current.session as { uuid: string }).uuid; + const otherUuid = (other.session as { uuid: string }).uuid; + + const req = makeReq( + { new_pass: 'a-fresh-password-123' }, + { + actor: { + ...actor, + session: { uid: currentUuid }, + } as typeof actor, + }, + ); + (req as unknown as { userProtected: unknown }).userProtected = { user }; + await controller.handleChangePassword(req, makeRes()); + + // The other session is revoked; the one that made the change survives. + expect(await server.stores.session.getByUuid(otherUuid)).toBeNull(); + expect( + await server.stores.session.getByUuid(currentUuid), + ).not.toBeNull(); + }); + it('change-username: 400 on missing/invalid/reserved/already-taken usernames', async () => { const { actor } = await makeUserAndActor(); await expect( @@ -3763,27 +3828,60 @@ describe('AuthController.handleMigrateToken', () => { ).rejects.toMatchObject({ statusCode: 403 }); }); - it('rejects when the Origin header is not in config.origin or the allowlist', async () => { + it('allows the exchange from an unlisted origin (apps live on arbitrary domains)', async () => { + // puter.js apps run on any third-party domain; the v1 bearer + // token is the credential, so the exchange itself is not + // origin-gated — only cookie issuance is (next test). const { user } = await makeUserAndActor(); const v1 = mintV1Token({ type: 'access-token', token_uid: uuidv4(), user_uid: user.uuid, }); - await expect( - controller.handleMigrateToken( - makeReq( - {}, - { - headers: { - origin: 'https://not-allowed.example', - authorization: `Bearer ${v1}`, - }, + const res = makeRes(); + await controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: 'https://some-app.example', + authorization: `Bearer ${v1}`, }, - ), - makeRes(), + }, ), - ).rejects.toMatchObject({ statusCode: 403 }); + res, + ); + expect((res.body as { kind: string }).kind).toBe('access_token'); + expect((res.body as { token: string }).token).toBeTruthy(); + }); + + it('does NOT set the puter_token_v2 cookie for an app token from an untrusted origin', async () => { + // Cookie planting on the GUI origin is what the allowlist + // prevents: an attacker page may exchange a token it already + // holds, but must not be able to set a session cookie. + const { user } = await makeUserAndActor(); + const appUid = `app-${uuidv4()}`; + const v1 = mintV1Token({ + type: 'app-under-user', + user_uid: user.uuid, + app_uid: appUid, + }); + const res = makeRes(); + await controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: 'https://some-app.example', + authorization: `Bearer ${v1}`, + }, + }, + ), + res, + ); + expect((res.body as { kind: string }).kind).toBe('app'); + expect((res.body as { token: string }).token).toBeTruthy(); + expect(res.cookies.puter_token_v2).toBeUndefined(); }); it('normalizes trailing slash on the request Origin (B4)', async () => { diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 4a182c51f..b5afe366a 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -99,7 +99,16 @@ export class AuthController extends PuterController { @Post('/login', { captcha: true, - rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 }, + // Two limits: per-fingerprint keeps users behind a shared IP + // (offices, campuses) from throttling each other, while the + // coarser per-IP backstop stops an attacker from minting fresh + // fingerprint buckets by rotating client-controlled headers + // (User-Agent etc.). Same pattern on the other unauthenticated + // credential endpoints below. + rateLimit: [ + { scope: 'login', limit: 10, window: 15 * 60_000 }, + { scope: 'login-ip', limit: 50, window: 15 * 60_000, key: 'ip' }, + ], }) async handleLogin(req: Request, res: Response): Promise { const { username, email, password } = req.body; @@ -206,11 +215,15 @@ export class AuthController extends PuterController { @Post('/login/otp', { captcha: true, - rateLimit: { - scope: 'login-otp', - limit: 15, - window: 30 * 60_000, - }, + rateLimit: [ + { scope: 'login-otp', limit: 15, window: 30 * 60_000 }, + { + scope: 'login-otp-ip', + limit: 60, + window: 30 * 60_000, + key: 'ip', + }, + ], }) async handleLoginOtp(req: Request, res: Response): Promise { const { token, code } = req.body; @@ -266,11 +279,15 @@ export class AuthController extends PuterController { @Post('/login/recovery-code', { captcha: true, - rateLimit: { - scope: 'login-recovery', - limit: 10, - window: 60 * 60_000, - }, + rateLimit: [ + { scope: 'login-recovery', limit: 10, window: 60 * 60_000 }, + { + scope: 'login-recovery-ip', + limit: 40, + window: 60 * 60_000, + key: 'ip', + }, + ], }) async handleLoginRecoveryCode(req: Request, res: Response): Promise { const { token, code } = req.body; @@ -339,7 +356,10 @@ export class AuthController extends PuterController { @Post('/signup', { captcha: true, - rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 }, + rateLimit: [ + { scope: 'signup', limit: 10, window: 15 * 60_000 }, + { scope: 'signup-ip', limit: 50, window: 15 * 60_000, key: 'ip' }, + ], }) async handleSignup(req: Request, res: Response): Promise { const body = req.body ?? {}; @@ -1129,6 +1149,12 @@ export class AuthController extends PuterController { } await this.stores.user.invalidateById(user.id); + // A password reset is the "I think someone else has access" flow — + // evict every interactive session so a hijacked one doesn't survive. + await this.services.auth.revokeInteractiveSessionsForUserId( + user.id as number, + ); + res.send('Password successfully updated.'); } @@ -1165,6 +1191,10 @@ export class AuthController extends PuterController { change_email_confirm_token: null, }); + // Sign out every other web session (cascading to their derived + // rows); only the session that changed the password survives. + await this.services.auth.revokeAllSessions(req.actor!); + if (this.clients.email && user.email) { try { await this.clients.email.send( @@ -2140,12 +2170,15 @@ export class AuthController extends PuterController { // -- Access tokens ----------------------------------------------- async handleMigrateToken(req: Request, res: Response): Promise { - // 1. Origin lock. Reject anything that isn't same-origin to - // `config.origin` or in the explicit per-deployment allowlist. - // No Origin header → reject (this endpoint is browser-only by - // design; server-side callers should re-auth properly). + // 1. Browser-only gate. No Origin header → reject (server-side + // callers should re-auth properly). The exchange itself is open + // to any browser origin: puter.js apps live on arbitrary + // third-party domains, the v1 bearer token is the credential, + // and cookies are never read here — so a hostile page learns + // nothing it doesn't already hold. Origin *trust* only decides + // whether the `puter_token_v2` companion cookie is set below. const reqOrigin = req.headers.origin; - if (!reqOrigin || !this.#isMigrateTokenOriginAllowed(reqOrigin)) { + if (!reqOrigin) { throw new HttpError(403, 'Origin not allowed', { legacyCode: 'forbidden', }); @@ -2175,12 +2208,19 @@ export class AuthController extends PuterController { : undefined, }); // `puter_token_v2` is the cookie companion to v2 app tokens — - // the app runs in-browser (we already gated on Origin above) so - // the GUI's cookie-only middleware can authenticate subsequent - // calls from the same iframe without the client having to - // forward Authorization headers. Access tokens are programmatic - // (no browser cookie surface) so we deliberately skip them here. - if (result.kind === 'app') { + // it lets the GUI's cookie-only middleware authenticate + // subsequent same-origin calls without the client forwarding + // Authorization headers. Issuing it is gated on the trusted + // origin allowlist: an arbitrary attacker page must not be able + // to plant a session cookie on the GUI origin (login CSRF). + // Untrusted origins still get the token in the JSON body — the + // cookie is only consumed same-origin (see authProbe) so they + // lose nothing. Access tokens are programmatic (no browser + // cookie surface) so we deliberately skip them here. + if ( + result.kind === 'app' && + this.#isMigrateTokenOriginAllowed(reqOrigin) + ) { res.cookie('puter_token_v2', result.token, { ...sessionCookieFlags(this.config), httpOnly: true, @@ -2485,6 +2525,9 @@ export class AuthController extends PuterController { }); await this.stores.group.addUsers(uid, users); + // New members inherit the group's permissions immediately, not + // after the permission-cache TTL. + await this.services.permission.bumpPermissionCacheForUsernames(users); res.json({}); } @@ -2514,6 +2557,9 @@ export class AuthController extends PuterController { }); await this.stores.group.removeUsers(uid, users); + // Removed members must lose the group's permissions immediately, + // not after the permission-cache TTL. + await this.services.permission.bumpPermissionCacheForUsernames(users); res.json({}); } diff --git a/src/backend/controllers/drivers/DriverController.ts b/src/backend/controllers/drivers/DriverController.ts index 6531b59ff..9214ad1fd 100644 --- a/src/backend/controllers/drivers/DriverController.ts +++ b/src/backend/controllers/drivers/DriverController.ts @@ -35,6 +35,7 @@ import { resolveDriverMethodRateLimit, } from '../../drivers/meta.js'; import type { PermissionService } from '../../services/permission/PermissionService.js'; +import { PermissionUtil } from '../../services/permission/permissionUtil.js'; import type { WithLifecycle } from '../../types'; import { PuterController } from '../types.js'; @@ -222,7 +223,16 @@ export class DriverController extends PuterController { | PermissionService | undefined; if (permService) { - const permKey = `service:${resolvedDriverName}:ii:${ifaceName}`; + // Build via PermissionUtil.join so any `:` in a driver or + // interface name is escaped — raw interpolation would let a + // crafted name shift permission-segment boundaries and match + // a broader/narrower parent than intended in the scan logic. + const permKey = PermissionUtil.join( + 'service', + String(resolvedDriverName), + 'ii', + ifaceName, + ); const hasPermission = await permService.check( req.actor, permKey, diff --git a/src/backend/controllers/fs/FSController.test.ts b/src/backend/controllers/fs/FSController.test.ts index ee84b571a..2231940d7 100644 --- a/src/backend/controllers/fs/FSController.test.ts +++ b/src/backend/controllers/fs/FSController.test.ts @@ -434,6 +434,65 @@ describe('FSController.completeBatchWrites', () => { expect(finalized?.wasOverwrite).toBe(true); }); + // Regression: a signed (direct-to-S3) upload could declare a tiny size + // and PUT far more — the presigned URL doesn't bound the body. The + // completion path must reconcile the recorded size against the object's + // true size, or storage accounting is permanently understated and the + // quota is bypassable. + it('reconciles the recorded size to the real uploaded bytes (ignores under-declared size)', async () => { + const { actor, userId } = await makeUser(); + const username = actor.user!.username!; + const target = `/${username}/Documents/under-declared.bin`; + + // Declare 1 byte. + const start = makeRes(); + await withActor(actor, () => + controller.startBatchWrites( + makeReq({ + body: [{ fileMetadata: { path: target, size: 1 } }], + actor, + }), + start.res, + ), + ); + const [started] = start.captured.body as SignedWriteResponse[]; + + // Actually upload 4096 bytes to the session's object key (simulating + // a client that PUTs more than it declared via the signed URL). + const realBytes = Buffer.alloc(4096, 0x41); + await server.stores.s3Object.uploadFromServer( + { + bucket: started!.bucket, + objectKey: started!.objectKey, + contentType: 'application/octet-stream', + body: realBytes, + contentLength: realBytes.byteLength, + }, + started!.bucketRegion, + ); + + const complete = makeRes(); + await withActor(actor, () => + controller.completeBatchWrites( + makeReq({ + body: [{ uploadId: started!.sessionId }], + actor, + }), + complete.res, + ), + ); + + const entry = await server.stores.fsEntry.getEntryByPath(target); + expect(entry).not.toBeNull(); + // Recorded size is the true 4096 bytes, not the declared 1. + expect(entry?.size).toBe(4096); + + // And the user's accounted usage reflects the real bytes. + const allowance = + await server.stores.fsEntry.getUserStorageAllowance(userId); + expect(allowance.curr).toBeGreaterThanOrEqual(4096); + }); + it('emits updated events with GUI metadata when overwriting via batch completion', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index 139a37682..2962272a3 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -37,6 +37,7 @@ import { runWithConcurrencyLimit, runWithConcurrencyLimitSettled, } from '../../util/concurrency.js'; +import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js'; import { PuterController } from '../types.js'; import { FS_COSTS } from './costs.js'; import type { @@ -964,8 +965,13 @@ export class FSController extends PuterController { range, }); - if (download.contentType) + if (download.contentType) { res.setHeader('Content-Type', download.contentType); + // Stored type is uploader-controlled and served inline on the + // api origin — sandbox active-document types (HTML/SVG/XML) so + // embedded scripts can't run here. Inert types are untouched. + applyInlineContentSecurity(res, download.contentType); + } if (download.contentLength !== null) res.setHeader('Content-Length', String(download.contentLength)); if (download.contentRange) diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 5e4b50091..674a63916 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -29,6 +29,7 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js'; import type { ACLService } from '../../services/acl/ACLService.js'; import type { SignedFile } from '../../util/fileSigning.js'; import { verifySignature } from '../../util/fileSigning.js'; +import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js'; import { PuterController } from '../types.js'; import { FS_COSTS } from './costs.js'; import { @@ -1357,8 +1358,15 @@ export class LegacyFSController extends PuterController { query.download === '1' || query.download === true; - if (download.contentType) + if (download.contentType) { res.setHeader('Content-Type', download.contentType); + // Uploader-controlled type served inline on the file origin — + // sandbox active-document types so an uploaded HTML/SVG can't + // execute scripts here. Mirrors FSController /fs/read. + if (!wantsAttachment) { + applyInlineContentSecurity(res, download.contentType); + } + } if (download.contentLength !== null) res.setHeader('Content-Length', String(download.contentLength)); if (download.contentRange) diff --git a/src/backend/controllers/peer/PeerController.ts b/src/backend/controllers/peer/PeerController.ts index 0a1505963..9b18f228a 100644 --- a/src/backend/controllers/peer/PeerController.ts +++ b/src/backend/controllers/peer/PeerController.ts @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import type { Request, Response } from 'express'; import type { Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; @@ -24,6 +25,21 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { PuterController } from '../types.js'; import { PEER_COSTS } from './costs.js'; +/** + * Constant-time secret comparison for the internal-auth header. HMAC both + * sides under a random per-process key to a fixed 32-byte digest first: + * this avoids leaking length via an early-return and sidesteps + * `timingSafeEqual`'s equal-length requirement for arbitrary-length + * inputs. The key need not persist — it only has to be unknown to the + * attacker for the duration of the comparison. + */ +const COMPARE_KEY = randomBytes(32); +const secretsEqual = (a: string, b: string): boolean => { + const ha = createHmac('sha256', COMPARE_KEY).update(a).digest(); + const hb = createHmac('sha256', COMPARE_KEY).update(b).digest(); + return timingSafeEqual(ha, hb); +}; + /** * Encode a UUID (or `app-` UID) as base64url with no padding. * Strips an `app-` prefix and dashes, then reinterprets the hex bytes. @@ -170,7 +186,11 @@ export class PeerController extends PuterController { } const expectedSecret = cfg.internal_auth_secret; const header = req.headers['x-puter-internal-auth']; - if (!expectedSecret || header !== expectedSecret) { + if ( + !expectedSecret || + typeof header !== 'string' || + !secretsEqual(header, expectedSecret) + ) { throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' }); } diff --git a/src/backend/core/http/middleware/rateLimit.test.js b/src/backend/core/http/middleware/rateLimit.test.js index 7bc0663ef..7220e12b4 100644 --- a/src/backend/core/http/middleware/rateLimit.test.js +++ b/src/backend/core/http/middleware/rateLimit.test.js @@ -140,6 +140,67 @@ describe('rateLimitGate — memory backend (default)', () => { expect(isHttpError(re)).toBe(true); }); + // Stacked gates, mirroring the materializer's handling of a + // `rateLimit: [...]` array (each entry is its own gate; a rejection + // short-circuits the chain). This is the credential-endpoint pattern: + // per-fingerprint budget for fairness on shared IPs, per-IP backstop + // against header rotation. + const runStack = async (stack, req) => { + for (const opts of stack) { + const err = await runGate(opts, req); + if (err) return err; + } + return undefined; + }; + + it('stacked gates: per-IP backstop catches header rotation that escapes the fingerprint key', async () => { + const stack = [ + { limit: 3, window: 60_000, scope: 'stack-fp' }, + { limit: 6, window: 60_000, key: 'ip', scope: 'stack-ip' }, + ]; + // Rotating the User-Agent mints a fresh fingerprint bucket every + // request, so the fingerprint gate admits all of these — the IP + // backstop must cap the total anyway. + for (let i = 0; i < 6; i++) { + const arg = await runStack( + stack, + makeReq({ headers: { 'user-agent': `rotated-ua-${i}` } }), + ); + expect(arg).toBeUndefined(); + } + const rejected = await runStack( + stack, + makeReq({ headers: { 'user-agent': 'rotated-ua-final' } }), + ); + expect(isHttpError(rejected)).toBe(true); + expect(rejected.statusCode).toBe(429); + }); + + it('stacked gates: clients sharing an IP each get their own fingerprint budget under the IP cap', async () => { + const stack = [ + { limit: 2, window: 60_000, scope: 'stack2-fp' }, + { limit: 10, window: 60_000, key: 'ip', scope: 'stack2-ip' }, + ]; + // Two clients (distinct UAs) behind one NAT IP: each gets the + // full per-fingerprint budget, and each is cut off by its own + // fingerprint bucket — not starved by the other's traffic. + for (const ua of ['alice-browser', 'bob-browser']) { + for (let i = 0; i < 2; i++) { + expect( + await runStack( + stack, + makeReq({ headers: { 'user-agent': ua } }), + ), + ).toBeUndefined(); + } + const rejected = await runStack( + stack, + makeReq({ headers: { 'user-agent': ua } }), + ); + expect(isHttpError(rejected)).toBe(true); + } + }); + it("falls back to fingerprint when 'user' strategy is selected but no actor present", async () => { // The fallback prevents anonymous traffic from sharing a single // bucket (which would invite trivial DoS via global rate-limit). diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 8c4287a9b..99a38aad7 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -66,6 +66,22 @@ export type RoutePath = string | RegExp | Array; * in the chain. Commented-out slots are reserved for the next chunks * (body parsing, post-auth gates, timing). */ +/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */ +export interface RouteRateLimit { + limit: number; + window: number; + /** + * Per-subscription overrides for `limit` (`SubscriptionPolicy.id` + * → cap). Same mechanic as `concurrent.bySubscription`: resolved + * via the metering service per request; falls back to the base + * `limit` when there's no actor / no metering / no match. + */ + bySubscription?: Record; + key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string); + scope?: string; + backend?: 'memory' | 'redis' | 'kv'; +} + export interface RouteOptions { /** Extra per-route middleware. Applied after built-in gates, before the handler. */ middleware?: RequestHandler[]; @@ -209,21 +225,15 @@ export interface RouteOptions { * All registered backends stay co-resident at runtime, so different * routes can pick whichever fits their access pattern. Omitting * `backend` uses the server-wide default (`config.rate_limit.backend`). + * + * An array applies every limit independently (a request must pass all + * of them). Use this to pair a per-client budget with a coarser + * backstop on a different key — e.g. per-fingerprint for fairness on + * shared IPs, plus per-IP so rotating headers can't mint fresh + * fingerprint buckets indefinitely. Give each entry its own `scope` + * so the counters don't collide. */ - rateLimit?: { - limit: number; - window: number; - /** - * Per-subscription overrides for `limit` (`SubscriptionPolicy.id` - * → cap). Same mechanic as `concurrent.bySubscription`: resolved - * via the metering service per request; falls back to the base - * `limit` when there's no actor / no metering / no match. - */ - bySubscription?: Record; - key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string); - scope?: string; - backend?: 'memory' | 'redis' | 'kv'; - }; + rateLimit?: RouteRateLimit | RouteRateLimit[]; /** * Concurrent in-flight limiting. Caps how many requests for this diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 725bf8444..982f94797 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -52,6 +52,12 @@ const INDEX_URL_UNIQUENESS_EXEMPTION_CANDIDATES = [ 'https://dev-center.puter.com/coming-soon', ]; +// Sentinel host for builtin apps. The GUI rewrites index_urls on this +// host to `/builtin/` (see launch_app.js), so rows +// carrying it are reserved for migration-seeded builtins — a user app +// claiming it would load its code same-origin with the desktop. +const BUILTIN_APPS_HOST = 'builtins.namespaces.puter.com'; + // Canonical-uid alias namespace. When a user-created app is merged into // an existing origin-bootstrap row, the source uid is mapped to the // canonical (kept) uid so any client that still holds the old uid keeps @@ -465,7 +471,7 @@ export class AppDriver extends PuterDriver { // -- Validation --------------------------------------------------- - async #validateInput(object, { isCreate }) { + async #validateInput(object, { isCreate, existing }) { const out = {}; if (isCreate || object.name !== undefined) { @@ -497,6 +503,15 @@ export class AppDriver extends PuterDriver { maxLen: 3000, required: isCreate, }); + // Only enforce on new/changed values so rows that already + // carry a reserved host (migration-seeded builtins) can still + // have their other fields updated. + if ( + out.index_url !== undefined && + out.index_url !== existing?.index_url + ) { + this.#assertIndexUrlHostAllowed(out.index_url); + } } if (object.icon !== undefined) { validateString(object.icon, { @@ -581,6 +596,54 @@ export class AppDriver extends PuterDriver { return out; } + /** + * App iframes run with `allow-same-origin allow-scripts`, so an + * index_url loading from the GUI host would execute third-party code + * same-origin with the desktop — a full sandbox escape. The API host + * is reserved for the same reason, and the builtin sentinel host is + * rewritten by the GUI to `/builtin/…` (see + * BUILTIN_APPS_HOST above). Host comparison (rather than full origin) + * deliberately also catches scheme/port variants of these hosts. + */ + #assertIndexUrlHostAllowed(indexUrl) { + let hostname; + try { + hostname = new URL(indexUrl).hostname; + } catch { + // Unparseable values are rejected by `validateUrl` upstream. + return; + } + + const config = this.config ?? {}; + const reserved = new Set([BUILTIN_APPS_HOST]); + // `origin`/`api_base_url` are computed at boot from `domain`; the + // domain-based fallbacks cover callers (tests, embedders) that + // construct a server without that normalization step. + const candidates = [config.origin, config.api_base_url]; + if (config.domain) { + candidates.push( + `http://${config.domain}`, + `http://api.${config.domain}`, + ); + } + for (const candidate of candidates) { + if (!candidate) continue; + try { + reserved.add(new URL(candidate).hostname); + } catch { + // Malformed config value — nothing to reserve from it. + } + } + + if (reserved.has(hostname)) { + throw new HttpError( + 400, + '`index_url` cannot point at a Puter system host', + { legacyCode: 'bad_request' }, + ); + } + } + // -- Permission checks -------------------------------------------- #requireActor() { diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index d4d896366..3b638586c 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -143,6 +143,57 @@ describe('AppDriver.create', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); + // App iframes get `allow-same-origin allow-scripts`, so an index_url + // on a Puter system host would run third-party code same-origin with + // the GUI. The test server's domain is `puter.localhost` (from + // config.default.json). + it.each([ + ['the GUI host', 'https://puter.localhost/evil.html'], + ['the GUI host on another port/scheme', 'http://puter.localhost:4100/evil.html'], + ['the API host', 'https://api.puter.localhost/evil.html'], + ['the builtin sentinel host', 'https://builtins.namespaces.puter.com/emulator'], + ])('rejects an index_url on %s with 400', async (_label, index_url) => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.create({ + object: { + name: uniqueName('sys-host'), + title: 't', + index_url, + }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: /system host/, + }); + }); + + it('rejects updating an index_url to a Puter system host with 400', async () => { + const { actor } = await makeUser(); + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('sys-host-upd'), + title: 't', + index_url: uniqueIndexUrl(), + }, + }), + ); + await expect( + withActor(actor, () => + driver.update({ + uid: created.uid, + object: { index_url: 'https://puter.localhost/evil.html' }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + message: /system host/, + }); + }); + it('rejects a duplicate app name with 400', async () => { const a = await makeUser(); const b = await makeUser(); diff --git a/src/backend/package.json b/src/backend/package.json index d15c22d64..80350b347 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -69,6 +69,7 @@ "svg-captcha": "^1.4.0", "together-ai": "^0.33.0", "ua-parser-js": "^1.0.41", + "undici": "^7.25.0", "uglify-js": "^3.17.4", "uuid": "^14.0.0", "validator": "^13.15.35" diff --git a/src/backend/server.ts b/src/backend/server.ts index 7148d5b46..c5bffaff2 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -380,6 +380,14 @@ export class PuterServer { this.#app.use(helmet.ieNoOpen()); this.#app.use(helmet.permittedCrossDomainPolicies()); this.#app.use(helmet.xssFilter()); + // Don't leak full URLs (which can carry signed tokens / file paths) + // to cross-origin destinations. Per-user hosted sites tighten this + // further to `no-referrer` in the puterSite middleware. + this.#app.use( + helmet.referrerPolicy({ + policy: 'strict-origin-when-cross-origin', + }), + ); this.#app.disable('x-powered-by'); // Cross-Origin-Resource-Policy: always allow cross-origin reads. @@ -875,11 +883,15 @@ export class PuterServer { } // 2b. Rate limiting. Runs after auth so 'user' key strategy - // has access to req.actor. + // has access to req.actor. An array applies each limit as its + // own gate — a request must pass all of them. if (opts.rateLimit) { - mwChain.push( - rateLimitGate(opts.rateLimit) as unknown as RequestHandler, - ); + const limits = Array.isArray(opts.rateLimit) + ? opts.rateLimit + : [opts.rateLimit]; + for (const rl of limits) { + mwChain.push(rateLimitGate(rl) as unknown as RequestHandler); + } } // 2b'. Concurrent in-flight limiting. Same auth-ordering reason diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 07c99f09b..302be5a2e 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -609,6 +609,24 @@ export class AuthService extends PuterService { } } + /** + * Password-reset cascade: revoke every interactive (web/app) session + * for the user, so a hijacked session doesn't outlive a password + * reset. No actor context — the recovery flow has no authenticated + * caller. Leaves workers and standalone access tokens alone: those + * are managed credentials rather than sign-ins, and a routine + * forgot-password reset shouldn't break deployments. + */ + async revokeInteractiveSessionsForUserId(userId: number): Promise { + if (!userId) return; + const rows = await this.stores.session.getByUserId(userId); + for (const row of rows) { + if (row.kind === 'web' || row.kind === 'app') { + await this.stores.session.revokeCascade(row.uuid as string); + } + } + } + async revokeAllSessions( actor: Actor, opts: { includeCurrent?: boolean; includeApps?: boolean } = {}, diff --git a/src/backend/services/auth/TokenService.test.ts b/src/backend/services/auth/TokenService.test.ts index a1ca01d3a..b3044eeb8 100644 --- a/src/backend/services/auth/TokenService.test.ts +++ b/src/backend/services/auth/TokenService.test.ts @@ -65,6 +65,74 @@ describe('TokenService.onServerStart', () => { const svc = new TokenService(config, clients, stores, services); expect(() => svc.onServerStart()).toThrow(/jwt_secret_v2/); }); + + it('refuses to start outside dev with the placeholder secrets from config.default.json', () => { + const config = { + env: 'prod', + jwt_secret: V1_SECRET, + jwt_secret_v2: 'dev-jwt-secret-v2-change-me', + } as ConstructorParameters[0]; + const [clients, stores, services] = [{}, {}, {}] as [ + ConstructorParameters[1], + ConstructorParameters[2], + ConstructorParameters[3], + ]; + const svc = new TokenService(config, clients, stores, services); + expect(() => svc.onServerStart()).toThrow(/placeholder/); + }); + + it('refuses to start outside dev with the placeholder url_signature_secret', () => { + const config = { + env: 'prod', + jwt_secret: 'a-real-v1-secret', + jwt_secret_v2: 'a-real-v2-secret', + url_signature_secret: 'dev-url-signature-secret-change-me', + } as ConstructorParameters[0]; + const [clients, stores, services] = [{}, {}, {}] as [ + ConstructorParameters[1], + ConstructorParameters[2], + ConstructorParameters[3], + ]; + const svc = new TokenService(config, clients, stores, services); + expect(() => svc.onServerStart()).toThrow( + /url_signature_secret.*placeholder/, + ); + }); + + it('allows a real non-dev secret that merely contains "change-me"', () => { + // The guard matches the exact shipped placeholders, not the + // "change-me" substring, so an operator's high-entropy secret that + // happens to include those characters must not be refused. + const config = { + env: 'prod', + jwt_secret: 'a-real-v1-secret', + jwt_secret_v2: 'kf83-change-me-not-the-placeholder-9af2', + url_signature_secret: 'another-real-secret', + } as ConstructorParameters[0]; + const [clients, stores, services] = [{}, {}, {}] as [ + ConstructorParameters[1], + ConstructorParameters[2], + ConstructorParameters[3], + ]; + const svc = new TokenService(config, clients, stores, services); + expect(() => svc.onServerStart()).not.toThrow(); + }); + + it('allows the placeholder secrets in dev', () => { + const config = { + env: 'dev', + jwt_secret: 'dev-jwt-secret-change-me', + jwt_secret_v2: 'dev-jwt-secret-v2-change-me', + url_signature_secret: 'dev-url-signature-secret-change-me', + } as ConstructorParameters[0]; + const [clients, stores, services] = [{}, {}, {}] as [ + ConstructorParameters[1], + ConstructorParameters[2], + ConstructorParameters[3], + ]; + const svc = new TokenService(config, clients, stores, services); + expect(() => svc.onServerStart()).not.toThrow(); + }); }); describe('TokenService.sign', () => { @@ -235,3 +303,45 @@ describe('TokenService.verify — v1 fallback', () => { expect(payload).toMatchObject({ type: 'session', legacy: true }); }); }); + +describe('TokenService.verify — algorithm pinning', () => { + // jsonwebtoken has always defaulted to HS256 for string secrets, so + // every token ever minted by Puter (v1 and v2) is HS256 — pinning + // `algorithms: ['HS256']` must not invalidate any existing token. + it('existing tokens keep working: minted v2 and default-signed v1 tokens are HS256 and verify', () => { + const svc = createTokenService(); + const v2 = svc.sign('auth', { + type: 'session', + user_uid: 'uu', + session_uid: 'su', + auth_id: 'ai', + }); + expect(jwt.decode(v2, { complete: true })).toMatchObject({ + header: { alg: 'HS256' }, + }); + expect(() => svc.verify('auth', v2)).not.toThrow(); + + const v1 = mintV1Token({ t: 's', uu: 'user-uuid' }); + expect(jwt.decode(v1, { complete: true })).toMatchObject({ + header: { alg: 'HS256' }, + }); + expect(svc.verify>('auth', v1)).toMatchObject({ + legacy: true, + }); + }); + + it('rejects a v2-routed token signed with a non-HS256 algorithm, even with the right secret', () => { + const svc = createTokenService(); + const token = jwt.sign({ t: 's' }, V2_SECRET, { + algorithm: 'HS384', + keyid: 'v2', + }); + expect(() => svc.verify('auth', token)).toThrow(/algorithm/); + }); + + it('rejects a v1-routed token signed with a non-HS256 algorithm, even with the right secret', () => { + const svc = createTokenService(); + const token = jwt.sign({ t: 's' }, V1_SECRET, { algorithm: 'HS512' }); + expect(() => svc.verify('auth', token)).toThrow(/algorithm/); + }); +}); diff --git a/src/backend/services/auth/TokenService.ts b/src/backend/services/auth/TokenService.ts index c3da79946..5b2093884 100644 --- a/src/backend/services/auth/TokenService.ts +++ b/src/backend/services/auth/TokenService.ts @@ -179,6 +179,16 @@ export class V1TokensDisabledError extends Error { // -- TokenService ---------------------------------------------------- +// The exact secret values shipped in config.default.json. Matched exactly +// (not by substring) so the boot guard refuses only these known-insecure +// defaults and never a legitimate operator secret that happens to contain +// "change-me". +const SHIPPED_PLACEHOLDER_SECRETS = new Set([ + 'dev-jwt-secret-change-me', + 'dev-jwt-secret-v2-change-me', + 'dev-url-signature-secret-change-me', +]); + export class TokenService extends PuterService { #secretV2: string = ''; #secretLegacy: string = ''; @@ -191,6 +201,32 @@ export class TokenService extends PuterService { 'TokenService requires `jwt_secret_v2` in config — v2 signing cannot proceed without it', ); } + // The dev placeholders ship in config.default.json (a public repo) and + // survive a deep-merge when an override config omits them — anyone who + // knows the placeholder can forge with that secret. For the JWT secrets + // that means forging a session token for any user; for + // `url_signature_secret` it means forging file read/write capability + // URLs for any file uid. Refuse to boot a non-dev deployment on any + // placeholder secret. (`url_signature_secret` is guarded here, with the + // other shipped placeholder secrets, even though it's consumed + // elsewhere — this is the one hook guaranteed to run before any request.) + if (this.config.env !== 'dev') { + for (const [name, value] of [ + ['jwt_secret_v2', secretV2], + ['jwt_secret', this.config.jwt_secret ?? ''], + [ + 'url_signature_secret', + this.config.url_signature_secret ?? '', + ], + ] as const) { + if (SHIPPED_PLACEHOLDER_SECRETS.has(value)) { + throw new Error( + `\`${name}\` is still the dev placeholder from config.default.json; ` + + 'set a real secret before running with env != "dev"', + ); + } + } + } this.#secretV2 = secretV2; // Legacy secret is optional in fresh installs (no v1 tokens to verify), // but every existing deployment carries one. Don't fail boot if it's @@ -241,6 +277,8 @@ export class TokenService extends PuterService { if (kid === 'v2') { const payload = jwt.verify(token, this.#secretV2, { clockTolerance: CLOCK_TOLERANCE_SECONDS, + // Secrets are symmetric; never accept asymmetric algs here. + algorithms: ['HS256'], }) as Record; return this.#decompressPayload(context, payload) as unknown as T; } @@ -269,6 +307,7 @@ export class TokenService extends PuterService { } const payload = jwt.verify(token, this.#secretLegacy, { clockTolerance: CLOCK_TOLERANCE_SECONDS, + algorithms: ['HS256'], }) as Record; const decompressed = this.#decompressPayload( context, diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 9324c9d13..691d0268b 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -2133,6 +2133,46 @@ export class FSService extends PuterService { ); } + // The size recorded so far is the client-declared value from the + // start-write request. On a signed (direct-to-S3) upload the + // client could declare `1` and PUT gigabytes — the presigned URL + // doesn't bound the body — so reconcile against the object's true + // size before persisting. Without this, storage accounting is + // understated permanently (quota is SUM(size)) and the free-tier + // limit is bypassable. Mirrors the server-proxied /write path. + const reconcileBucket = + session.bucket ?? createInput.bucket ?? this.#resolveBucket(); + const reconcileRegion = + session.bucketRegion ?? + createInput.bucketRegion ?? + this.#resolveBucketRegion(); + let trueSize: number | null = null; + try { + trueSize = await this.stores.s3Object.headObjectSize( + reconcileBucket, + session.objectKey, + reconcileRegion, + ); + } catch { + // HEAD failed (e.g. object never uploaded) — leave the + // declared size; don't block completion on a metadata read. + trueSize = null; + } + if (typeof trueSize === 'number' && trueSize >= 0) { + // Record the true size only — do not re-assert the quota here. + // The bytes are already in S3, so a completion-time reject + // can't reclaim them; it only false-rejects (the start-check + // may have used a higher storageAllowanceMax override that + // isn't persisted in the session) and deletes within-quota + // uploads. Recording real bytes is what closes the bypass: + // the user's SUM(size) becomes accurate so their next signed + // -write start-check (#assertStorageAllowance via + // getUserStorageAllowance) blocks them. The residual is a + // single in-flight upload over quota — the same bounded + // check-then-act window the start-check already has. + createInput.size = trueSize; + } + const fsEntry = await this.stores.fsEntry.completePendingEntry( session.sessionId, createInput, @@ -2313,6 +2353,49 @@ export class FSService extends PuterService { throw new Error('Failed to complete multipart upload'); } + // Reconcile client-declared sizes against the true uploaded object + // sizes before persisting — see completeUrlWrite for the rationale. + // Without this the batch endpoint is a parallel bypass of the same + // storage-quota check. + const reconcileBucketRegion = ( + item: (typeof completionItems)[number], + ) => ({ + bucket: + item.session.bucket ?? + item.finalData.bucket ?? + this.#resolveBucket(), + region: + item.session.bucketRegion ?? + item.finalData.bucketRegion ?? + this.#resolveBucketRegion(), + }); + const headSizes = await Promise.all( + completionItems.map(async (item) => { + const { bucket, region } = reconcileBucketRegion(item); + try { + return await this.stores.s3Object.headObjectSize( + bucket, + item.session.objectKey, + region, + ); + } catch { + return null; + } + }), + ); + // Record the true sizes only — do not re-assert the quota here. See + // completeUrlWrite: the bytes are already in S3 so a completion-time + // reject can't reclaim them, and per-item deletes would destroy the + // bytes of correctly-declared, within-quota siblings in the batch. + // Recording real sizes keeps SUM(size) accurate so the next + // signed-write start-check blocks an over-quota user. + for (let index = 0; index < completionItems.length; index++) { + const item = completionItems[index]; + const trueSize = headSizes[index]; + if (typeof trueSize !== 'number' || trueSize < 0) continue; + item.finalData.size = trueSize; + } + const completedEntries = await this.stores.fsEntry.batchCompletePendingEntries( completionItems.map((item) => ({ diff --git a/src/backend/services/permission/PermissionService.test.ts b/src/backend/services/permission/PermissionService.test.ts index bcc51346b..263e20327 100644 --- a/src/backend/services/permission/PermissionService.test.ts +++ b/src/backend/services/permission/PermissionService.test.ts @@ -23,10 +23,12 @@ import type { Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { kv } from '../../util/kvSingleton.js'; import { PermissionService } from './PermissionService.js'; function createPermissionService(): PermissionService { const permissionStore = { + getCacheGeneration: async () => 0, getMultiCheckCache: async () => new Map(), setMultiCheckCache: async () => undefined, }; @@ -622,16 +624,109 @@ describe('PermissionService (integration)', () => { }); }); - describe('invalidatePermissionScanCacheForAppUnderUser', () => { - it('runs to completion against the real permission store', async () => { - const { user } = await makeUserActor(); - await expect( - permService.invalidatePermissionScanCacheForAppUnderUser( - user.uuid, - `app-${uuidv4()}`, - 'service:foo:ii:read', + describe('cache-generation invalidation on grant/revoke', () => { + // The grant/revoke paths bump the holder's per-actor cache + // generation so a change takes effect on the very next check + // rather than after the scan-cache TTL. These exercise the real + // Redis-backed (ioredis-mock) cache via the live permission store. + const grantManage = async ( + issuer: { id: number }, + permission: string, + ) => { + await server.stores.permission.setFlatUserPerm( + issuer.id, + `manage:${permission}`, + { + permission: `manage:${permission}`, + deleted: false, + issuer_user_id: issuer.id, + } as never, + ); + }; + + it('revoke is visible immediately — a cached "granted" reading is not served', async () => { + const { user: issuer, actor: issuerActor } = + await makeUserActor(); + const { user: target, actor: targetActor } = + await makeUserActor(); + const permission = `service:revoke-now-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + + await runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, ), - ).resolves.toBeUndefined(); + ); + + // Prime the cache: the holder sees the grant (this writes the + // scan/check cache under the current generation). + expect(await permService.check(targetActor, permission)).toBe( + true, + ); + + await runWithContext({ actor: issuerActor }, () => + permService.revokeUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + + // Without the generation bump this would still read `true` from + // the primed cache for up to the TTL. + expect(await permService.check(targetActor, permission)).toBe( + false, + ); + }); + + it('caches the generation in-process so repeat reads skip Redis, and a bump updates the local copy at once', async () => { + const { user } = await makeUserActor(); + const aUid = `user:${user.uuid}`; + const localKey = `permgen-local:${aUid}`; + + // Cold: nothing cached locally yet. + expect(kv.get(localKey)).toBeUndefined(); + + // First read populates the in-process cache (avoids a Redis GET + // on every subsequent permission check for this actor). + const g = await server.stores.permission.getCacheGeneration(aUid); + expect(kv.get(localKey)).toBe(g); + + // A bump makes this node consistent immediately — no waiting for + // the local TTL — so single-node revocation is instant. + await server.stores.permission.bumpCacheGeneration(aUid); + expect(kv.get(localKey)).toBe(g + 1); + expect(await server.stores.permission.getCacheGeneration(aUid)).toBe( + g + 1, + ); + }); + + it('grant is visible immediately — a cached "denied" reading is not served', async () => { + const { user: issuer, actor: issuerActor } = + await makeUserActor(); + const { user: target, actor: targetActor } = + await makeUserActor(); + const permission = `service:grant-now-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + + // Prime a "denied" reading into the cache. + expect(await permService.check(targetActor, permission)).toBe( + false, + ); + + await runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + + expect(await permService.check(targetActor, permission)).toBe( + true, + ); }); }); }); diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index b5e016788..0d2c7bc54 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -215,9 +215,12 @@ export class PermissionService extends PuterService { // -- Cache pass: one pipelined MGET -- const aUid = actorUid(actor); + const generation = + await this.stores.permission.getCacheGeneration(aUid); const cached = await this.stores.permission.getMultiCheckCache( aUid, dedup, + generation, ); const missing: string[] = []; for (const p of dedup) { @@ -251,8 +254,13 @@ export class PermissionService extends PuterService { // Backfill cache (best-effort, fire-and-forget would also be // fine — keeping it awaited so callers see deterministic state - // in tests). - await this.stores.permission.setMultiCheckCache(aUid, writeBack); + // in tests). Use the same generation read above so a concurrent + // bump doesn't get masked by this write. + await this.stores.permission.setMultiCheckCache( + aUid, + writeBack, + generation, + ); return out; } @@ -286,9 +294,15 @@ export class PermissionService extends PuterService { const workingState: ScanState = state ?? { antiCycleActors: [actor] }; // -- Redis scan cache -- + // The per-actor cache generation is folded into the key so a + // grant/revoke bump orphans this actor's cached readings at once. + const aUid = actorUid(actor); + const generation = + await this.stores.permission.getCacheGeneration(aUid); const cacheKey = this.stores.permission.buildScanCacheKey( - actorUid(actor), + aUid, options, + generation, ); if (!scanOptions.noCache) { const cached = await this.stores.permission.getScanCache(cacheKey); @@ -907,6 +921,9 @@ export class PermissionService extends PuterService { reason: meta.reason ?? 'granted via PermissionService', }) .catch(() => {}); + + // Bust any cached "denied" reading so the grant is live immediately. + if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid); } async revokeUserUserPermission( @@ -946,6 +963,9 @@ export class PermissionService extends PuterService { reason: meta.reason ?? 'revoked via PermissionService', }) .catch(() => {}); + + // The holder loses access on their next check, not after the TTL. + if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid); } async grantUserAppPermission( @@ -1002,12 +1022,9 @@ export class PermissionService extends PuterService { }) .catch(() => {}); - // Invalidate app-under-user scan cache so the grant takes effect immediately - await this.invalidatePermissionScanCacheForAppUnderUser( - actor.user.uuid!, - app.uid, - permission, - ); + // Bump the app-under-user cache generation so the grant takes + // effect on the next check rather than after the cache TTL. + await this.#bumpAppUnderUserCacheGeneration(actor.user.uuid!, app.uid); } async revokeUserAppPermission( @@ -1045,6 +1062,13 @@ export class PermissionService extends PuterService { reason: meta.reason ?? 'revoked via PermissionService', }) .catch(() => {}); + + if (actor.user.uuid) { + await this.#bumpAppUnderUserCacheGeneration( + actor.user.uuid, + app.uid, + ); + } } async revokeUserAppAll( @@ -1076,6 +1100,13 @@ export class PermissionService extends PuterService { reason: meta.reason ?? 'revoked all via PermissionService', }) .catch(() => {}); + + if (actor.user.uuid) { + await this.#bumpAppUnderUserCacheGeneration( + actor.user.uuid, + app.uid, + ); + } } async grantDevAppPermission( @@ -1217,6 +1248,8 @@ export class PermissionService extends PuterService { reason: meta.reason ?? 'granted via PermissionService', }) .catch(() => {}); + + await this.#bumpGroupMembersCacheGeneration(group.uid); } async revokeUserGroupPermission( @@ -1245,6 +1278,8 @@ export class PermissionService extends PuterService { reason: meta.reason ?? 'revoked via PermissionService', }) .catch(() => {}); + + await this.#bumpGroupMembersCacheGeneration(group.uid); } // -- Issuer queries (share discovery et al) ----------------------- @@ -1334,17 +1369,56 @@ export class PermissionService extends PuterService { } // -- Cache invalidation ------------------------------------------- + // + // Grants and revokes bump the affected holder's per-actor cache + // generation (see PermissionStore), which orphans all of that actor's + // cached scan/check readings at once. This is cluster-safe (a single + // INCR, no pattern scan) and makes a grant/revoke take effect on the + // holder's very next check rather than after the cache TTL. - async invalidatePermissionScanCacheForAppUnderUser( + /** Bump a plain user holder (`user:`). */ + async #bumpUserCacheGeneration(userUuid: string): Promise { + await this.stores.permission.bumpCacheGeneration(`user:${userUuid}`); + } + + /** Bump an app-under-user holder (`app-under-user::`). */ + async #bumpAppUnderUserCacheGeneration( userUuid: string, appUid: string, - permission: string, ): Promise { - const actorUid = `app-under-user:${userUuid}:${appUid}`; - const cacheKey = this.stores.permission.buildScanCacheKey(actorUid, [ - permission, - ]); - await this.stores.permission.invalidateScanCache(cacheKey); + await this.stores.permission.bumpCacheGeneration( + `app-under-user:${userUuid}:${appUid}`, + ); + } + + /** + * Bump every current member of a group. Used when a group grant + * changes — each member's readings may have resolved through the group, + * so each member's `user:` cache must be orphaned. + */ + async #bumpGroupMembersCacheGeneration(groupUid: string): Promise { + const memberUuids = + await this.stores.group.listMemberUserUuids(groupUid); + await Promise.all( + memberUuids.map((uuid) => this.#bumpUserCacheGeneration(uuid)), + ); + } + + /** + * Public: bump the permission cache for a set of users by username. + * Used by group add/remove-users — membership changes a user's + * effective permissions, so their cached readings must be orphaned + * (a removed user must lose the group's grants on their next check, + * not after the TTL). + */ + async bumpPermissionCacheForUsernames(usernames: string[]): Promise { + const unique = Array.from(new Set(usernames.filter(Boolean))); + await Promise.all( + unique.map(async (username) => { + const user = await this.stores.user.getByUsername(username); + if (user?.uuid) await this.#bumpUserCacheGeneration(user.uuid); + }), + ); } // -- Internals ---------------------------------------------------- diff --git a/src/backend/services/permission/consts.ts b/src/backend/services/permission/consts.ts index 8b945d5dc..3957853c8 100644 --- a/src/backend/services/permission/consts.ts +++ b/src/backend/services/permission/consts.ts @@ -30,6 +30,30 @@ export const PERMISSION_FOR_NOTHING_IN_PARTICULAR = /** TTL (seconds) for redis-cached permission scan readings. */ export const PERMISSION_SCAN_CACHE_TTL_SECONDS = 20; +/** + * TTL (seconds) for the per-actor cache-generation counter. A grant/revoke + * bumps this counter, which is folded into the scan/check cache keys so all + * of that actor's cached readings are orphaned at once (cluster-safe — a + * single-key INCR, no pattern scan). Must be comfortably longer than + * {@link PERMISSION_SCAN_CACHE_TTL_SECONDS} so the counter never lapses back + * to 0 while same-generation cache entries are still live (which would + * revive stale readings). Refreshed on every bump. + */ +export const PERMISSION_CACHE_GENERATION_TTL_SECONDS = 24 * 60 * 60; + +/** + * TTL (seconds) for the per-node in-process cache of the generation + * counter. Permission checks are very hot, so reading the counter from + * Redis on every check would add a round-trip to the hottest path. A tiny + * local cache collapses repeated reads (including the recursive + * issuer-scan and `checkMany` fan-out) to in-memory lookups. The authoritative + * counter still lives in Redis: a bump on any node propagates to the others + * within this window, so this is the cross-node revocation lag (the bumping + * node itself updates its local copy immediately and is consistent at once). + * Keep it short. + */ +export const PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS = 2; + /** * Sentinel grant for a "full API access" access token: the token may do * anything its issuing user can do via the API (filesystem, drivers, KV, AI, diff --git a/src/backend/services/selfhosted/DefaultUserService.ts b/src/backend/services/selfhosted/DefaultUserService.ts index 41ade8d31..edf568a8c 100644 --- a/src/backend/services/selfhosted/DefaultUserService.ts +++ b/src/backend/services/selfhosted/DefaultUserService.ts @@ -48,7 +48,10 @@ export class DefaultUserService extends PuterService { let tmpPassword: string; if (!user) { - tmpPassword = crypto.randomBytes(4).toString('hex'); + // 16 bytes (128 bits) — the old 4 bytes was only 32 bits, + // brute-forceable if the box is reachable before the admin + // rotates the printed bootstrap password. + tmpPassword = crypto.randomBytes(16).toString('hex'); user = await this.#createAdminUser(tmpPassword); // AppIconService is registered before us, so its own onServerStart // bailed on its first-boot bootstrap (admin didn't exist yet). diff --git a/src/backend/stores/fs/S3ObjectStore.ts b/src/backend/stores/fs/S3ObjectStore.ts index 080181fd0..11a524e38 100644 --- a/src/backend/stores/fs/S3ObjectStore.ts +++ b/src/backend/stores/fs/S3ObjectStore.ts @@ -25,6 +25,7 @@ import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, + HeadObjectCommand, PutObjectCommand, type S3Client, UploadPartCommand, @@ -362,6 +363,29 @@ export class S3ObjectStore extends PuterStore { ); } + /** + * Return the actual byte size of a stored object via a HEAD request, or + * null if S3 doesn't report a content length. Used to reconcile the + * authoritative size of a direct-to-S3 (signed-URL) upload against the + * client-declared size, which is otherwise untrusted. + */ + async headObjectSize( + bucket: string, + objectKey: string, + region: string, + ): Promise { + const client = this.#getClientForRegion(region); + const response = await client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: objectKey, + }), + ); + return typeof response.ContentLength === 'number' + ? response.ContentLength + : null; + } + async deleteObject( bucket: string, objectKey: string, diff --git a/src/backend/stores/group/GroupStore.ts b/src/backend/stores/group/GroupStore.ts index 589d43b98..e0bb9fc61 100644 --- a/src/backend/stores/group/GroupStore.ts +++ b/src/backend/stores/group/GroupStore.ts @@ -142,6 +142,22 @@ export class GroupStore extends PuterStore { return rows.map((r) => String(r.username)); } + /** + * UUIDs of a group's members — used to bump each member's permission + * cache generation when a group grant changes, so the grant/revoke + * takes effect for members without waiting for the cache TTL. + */ + async listMemberUserUuids(uid: string): Promise { + const rows = await this.clients.db.read( + 'SELECT u.uuid FROM `user` u ' + + 'JOIN (SELECT user_id FROM `jct_user_group` WHERE group_id = ' + + '(SELECT id FROM `group` WHERE uid = ?)) ug ' + + 'ON u.id = ug.user_id', + [uid], + ); + return rows.map((r) => String(r.uuid)); + } + // -- Writes ------------------------------------------------------- /** diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 26c7e2a17..97e72bf6f 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -23,8 +23,11 @@ import type { puterStores } from '../index'; import { PermissionUtil } from '../../services/permission/permissionUtil'; import { PERM_KEY_PREFIX, + PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS, + PERMISSION_CACHE_GENERATION_TTL_SECONDS, PERMISSION_SCAN_CACHE_TTL_SECONDS, } from '../../services/permission/consts'; +import { kv } from '../../util/kvSingleton'; import type { UserRow } from '../user/UserStore'; // Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters @@ -553,12 +556,88 @@ export class PermissionStore extends PuterStore { return rows.map((r) => String(r.permission)); } + // -- Cache generation (per-actor invalidation) ------------------- + // + // A monotonically increasing counter per actor, folded into the scan + // and check cache keys. Bumping it (a single-key INCR — cluster-safe, + // no CROSSSLOT pattern scan) instantly orphans every cached reading for + // that actor: subsequent lookups compute a new key and miss. + // + // The authoritative counter lives in shared Redis. Permission checks are + // extremely hot, so each node keeps a tiny in-process cache (the kv.js + // singleton, ~2s TTL) in front of the Redis read — this collapses the + // per-check round-trip and the `checkMany`/recursive-scan fan-out to + // in-memory lookups. A bump on any node updates that node's local copy + // immediately and propagates to other nodes within the local TTL, which + // is therefore the cross-node revocation lag. Old-generation cache keys + // expire via their own short TTL. + #cacheGenerationKey(actorUid: string): string { + return PermissionUtil.join('permission-cachegen', actorUid); + } + + #localCacheGenerationKey(actorUid: string): string { + return `permgen-local:${actorUid}`; + } + + async getCacheGeneration(actorUid: string): Promise { + const localKey = this.#localCacheGenerationKey(actorUid); + const local = kv.get(localKey); + if (typeof local === 'number') return local; + try { + const raw = await this.clients.redis.get( + this.#cacheGenerationKey(actorUid), + ); + const n = raw === null ? 0 : Number.parseInt(raw, 10); + const generation = Number.isFinite(n) && n >= 0 ? n : 0; + kv.set(localKey, generation, { + EX: PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS, + }); + return generation; + } catch { + // On a cache read failure, fall back to generation 0 — the worst + // case is a stale reading bounded by the scan-cache TTL. + return 0; + } + } + + async bumpCacheGeneration(actorUid: string): Promise { + const key = this.#cacheGenerationKey(actorUid); + try { + const next = await this.clients.redis.incr(key); + // Keep the counter alive well past the cache TTL so it can't + // reset to 0 and revive same-generation stale entries. + await this.clients.redis.expire( + key, + PERMISSION_CACHE_GENERATION_TTL_SECONDS, + ); + // Make this node consistent immediately; other nodes pick up the + // new value when their local copy expires (≤ local TTL). + if (typeof next === 'number') { + kv.set(this.#localCacheGenerationKey(actorUid), next, { + EX: PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS, + }); + } else { + kv.del(this.#localCacheGenerationKey(actorUid)); + } + } catch { + // Best-effort: if the bump fails, the scan-cache TTL still + // bounds how long a revoked grant can linger. Drop the local + // copy so we re-read from Redis rather than serve a stale gen. + kv.del(this.#localCacheGenerationKey(actorUid)); + } + } + // -- Scan cache (redis) ------------------------------------------ - buildScanCacheKey(actorUid: string, permissionOptions: string[]): string { + buildScanCacheKey( + actorUid: string, + permissionOptions: string[], + generation = 0, + ): string { return PermissionUtil.join( 'permission-scan', actorUid, + `g${generation}`, 'options-list', ...permissionOptions, ); @@ -594,14 +673,19 @@ export class PermissionStore extends PuterStore { // -- Per-permission check cache (for `checkMany`) ----------------- // // Cached as `1`/`0` per (actor, permission) pair so a batch lookup - // reduces to a single MGET. Same TTL as the scan cache — the - // underlying perm tables already publish invalidations via - // `publishCacheKeys` for grant/revoke writes, but those don't reach - // these keys, so we keep the TTL short and rely on it for staleness. - #checkCacheKey(actorUid: string, permission: string): string { + // reduces to a single MGET. Same TTL as the scan cache. The cache + // generation (see above) is folded into the key, so a grant/revoke + // bump invalidates these entries immediately rather than waiting for + // the TTL to lapse. + #checkCacheKey( + actorUid: string, + permission: string, + generation = 0, + ): string { return PermissionUtil.join( 'permission-check', actorUid, + `g${generation}`, 'p', permission, ); @@ -610,10 +694,13 @@ export class PermissionStore extends PuterStore { async getMultiCheckCache( actorUid: string, permissions: string[], + generation = 0, ): Promise> { const out = new Map(); if (permissions.length === 0) return out; - const keys = permissions.map((p) => this.#checkCacheKey(actorUid, p)); + const keys = permissions.map((p) => + this.#checkCacheKey(actorUid, p, generation), + ); let raw: Array = []; try { raw = (await this.clients.redis.mget(...keys)) as Array< @@ -633,13 +720,14 @@ export class PermissionStore extends PuterStore { async setMultiCheckCache( actorUid: string, entries: Array<{ permission: string; granted: boolean }>, + generation = 0, ttlSeconds: number = PERMISSION_SCAN_CACHE_TTL_SECONDS, ): Promise { if (entries.length === 0) return; const pipeline = this.clients.redis.pipeline(); for (const { permission, granted } of entries) { pipeline.set( - this.#checkCacheKey(actorUid, permission), + this.#checkCacheKey(actorUid, permission, generation), granted ? '1' : '0', 'EX', ttlSeconds, diff --git a/src/backend/types.ts b/src/backend/types.ts index 87b8d757d..1e2c67970 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -507,11 +507,13 @@ interface IConfigOptional { */ allow_v1_app_migration: boolean; /** - * Optional explicit allowlist of `Origin` header values that may - * call `POST /auth/migrate-token` cross-origin. The main `origin` - * is always allowed. Used to thread the SDK migration call through - * app subdomains (e.g. `*.puter.site`) without opening the - * endpoint to arbitrary attacker pages. + * Optional explicit allowlist of `Origin` header values that are + * trusted by `POST /auth/migrate-token` to receive the + * `puter_token_v2` companion cookie. The main `origin` is always + * trusted. The token exchange itself is open to any browser origin + * (the v1 bearer token is the credential); this list only gates + * cookie issuance, so attacker pages can't plant a session cookie + * on the GUI origin. */ allow_migrate_token_origins?: string[]; /** HMAC secret for signed file URLs (/file, /writeFile, /sign). */ diff --git a/src/backend/util/fileSigning.test.ts b/src/backend/util/fileSigning.test.ts new file mode 100644 index 000000000..df07e67e2 --- /dev/null +++ b/src/backend/util/fileSigning.test.ts @@ -0,0 +1,119 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import type { FSEntry } from '../stores/fs/FSEntry.js'; +import { + isSignatureValid, + signFile, + verifySignature, +} from './fileSigning.js'; + +const CONFIG = { + secret: 'a-real-secret-not-the-placeholder', + apiBaseUrl: 'https://api.example.test', +}; + +const makeEntry = (): FSEntry => + ({ + uuid: '11111111-2222-3333-4444-555555555555', + name: 'doc.txt', + isDir: false, + size: 10, + accessed: 0, + modified: 0, + created: 0, + }) as unknown as FSEntry; + +const queryFromUrl = (url: string) => { + const u = new URL(url); + return { + uid: u.searchParams.get('uid') ?? undefined, + expires: u.searchParams.get('expires') ?? undefined, + signature: u.searchParams.get('signature') ?? undefined, + }; +}; + +describe('fileSigning round-trip', () => { + it('a freshly signed read URL verifies for read', () => { + const signed = signFile(makeEntry(), CONFIG); + expect(() => + verifySignature(queryFromUrl(signed.read_url), 'read', CONFIG), + ).not.toThrow(); + }); + + it('a write signature also satisfies read (superset)', () => { + const signed = signFile(makeEntry(), CONFIG); + const q = queryFromUrl(signed.write_url!); + expect(isSignatureValid(q, 'write', CONFIG)).toBe(true); + expect(isSignatureValid(q, 'read', CONFIG)).toBe(true); + }); + + it('a read signature does NOT satisfy write', () => { + const signed = signFile(makeEntry(), CONFIG); + const q = queryFromUrl(signed.read_url); + expect(isSignatureValid(q, 'write', CONFIG)).toBe(false); + }); +}); + +describe('fileSigning rejection paths', () => { + it('rejects a tampered signature', () => { + const signed = signFile(makeEntry(), CONFIG); + const q = queryFromUrl(signed.read_url); + q.signature = (q.signature ?? '').replace(/^./, (c) => + c === 'a' ? 'b' : 'a', + ); + expect(isSignatureValid(q, 'read', CONFIG)).toBe(false); + }); + + it('rejects a signature uid swapped to a different file', () => { + const signed = signFile(makeEntry(), CONFIG); + const q = queryFromUrl(signed.read_url); + q.uid = '99999999-9999-9999-9999-999999999999'; + expect(isSignatureValid(q, 'read', CONFIG)).toBe(false); + }); + + it('rejects a signature minted under a different secret', () => { + const signed = signFile(makeEntry(), CONFIG); + const q = queryFromUrl(signed.read_url); + expect( + isSignatureValid(q, 'read', { + ...CONFIG, + secret: 'a-different-secret', + }), + ).toBe(false); + }); + + it('rejects an expired signature', () => { + const signed = signFile(makeEntry(), CONFIG, { ttlSeconds: -10 }); + expect(() => + verifySignature(queryFromUrl(signed.read_url), 'read', CONFIG), + ).toThrow(/expired/i); + }); + + it('rejects malformed (non-hex / wrong-length) signatures without throwing in the comparator', () => { + const signed = signFile(makeEntry(), CONFIG); + const q = queryFromUrl(signed.read_url); + for (const bad of ['', 'zz', 'not-hex-at-all', 'abc']) { + expect(isSignatureValid({ ...q, signature: bad }, 'read', CONFIG)).toBe( + false, + ); + } + }); +}); diff --git a/src/backend/util/fileSigning.ts b/src/backend/util/fileSigning.ts index 045c82548..d0d2e7d77 100644 --- a/src/backend/util/fileSigning.ts +++ b/src/backend/util/fileSigning.ts @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -import { createHash } from 'node:crypto'; +import { createHash, timingSafeEqual } from 'node:crypto'; import { HttpError } from '../core/http/HttpError.js'; import type { FSEntry } from '../stores/fs/FSEntry.js'; @@ -66,6 +66,23 @@ function computeSignature( return sha256(`${uid}/${action}/${secret}/${expires}`); } +/** + * Constant-time equality for the hex signature strings. A plain `===` + * short-circuits on the first differing character, leaking a byte-by-byte + * timing oracle on the one value an attacker controls and submits + * repeatedly. Length mismatch (or non-hex input) returns false without + * a timing-variable compare. + */ +function signaturesEqual(provided: string, expected: string): boolean { + if (provided.length !== expected.length) return false; + const a = Buffer.from(provided, 'hex'); + const b = Buffer.from(expected, 'hex'); + // Malformed hex yields a shorter buffer than the hex length implies; + // timingSafeEqual requires equal lengths, so guard before comparing. + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + /** * Produce a signed-URL object. The default `expires` timestamp uses a * ~317k-year TTL (effectively permanent) — existing clients depend on that @@ -152,9 +169,19 @@ export function verifySignature( } // Write signature satisfies any action. - if (signature === computeSignature(uid, 'write', config.secret, expires)) + if ( + signaturesEqual( + signature, + computeSignature(uid, 'write', config.secret, expires), + ) + ) return; - if (signature === computeSignature(uid, action, config.secret, expires)) + if ( + signaturesEqual( + signature, + computeSignature(uid, action, config.secret, expires), + ) + ) return; throw new HttpError(403, 'Authentication failed', { diff --git a/src/backend/util/inlineContentSecurity.test.ts b/src/backend/util/inlineContentSecurity.test.ts new file mode 100644 index 000000000..ce28dfbab --- /dev/null +++ b/src/backend/util/inlineContentSecurity.test.ts @@ -0,0 +1,74 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Response } from 'express'; +import { describe, expect, it } from 'vitest'; +import { applyInlineContentSecurity } from './inlineContentSecurity.js'; + +const makeRes = () => { + const headers: Record = {}; + return { + res: { + setHeader: (k: string, v: string) => { + headers[k.toLowerCase()] = v; + }, + } as unknown as Response, + headers, + }; +}; + +describe('applyInlineContentSecurity', () => { + it.each([ + 'text/html', + 'text/html; charset=utf-8', + 'image/svg+xml', + 'application/xhtml+xml', + 'application/xml', + 'text/xml', + 'TEXT/HTML', + ])('sandboxes active-document type %s', (ct) => { + const { res, headers } = makeRes(); + expect(applyInlineContentSecurity(res, ct)).toBe(true); + expect(headers['content-security-policy']).toBe( + "default-src 'none'; sandbox;", + ); + expect(headers['x-content-type-options']).toBe('nosniff'); + }); + + it.each([ + 'image/png', + 'image/jpeg', + 'audio/mpeg', + 'video/mp4', + 'application/octet-stream', + 'application/pdf', + 'text/plain', + ])('leaves inert type %s untouched', (ct) => { + const { res, headers } = makeRes(); + expect(applyInlineContentSecurity(res, ct)).toBe(false); + expect(headers['content-security-policy']).toBeUndefined(); + }); + + it('no-ops on null/undefined content type', () => { + const { res, headers } = makeRes(); + expect(applyInlineContentSecurity(res, null)).toBe(false); + expect(applyInlineContentSecurity(res, undefined)).toBe(false); + expect(headers['content-security-policy']).toBeUndefined(); + }); +}); diff --git a/src/backend/util/inlineContentSecurity.ts b/src/backend/util/inlineContentSecurity.ts new file mode 100644 index 000000000..78ca3fc22 --- /dev/null +++ b/src/backend/util/inlineContentSecurity.ts @@ -0,0 +1,51 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Response } from 'express'; + +// Content types a browser will execute as a *document* (running embedded +// scripts) when loaded top-level — even when the type is declared honestly. +// An uploaded SVG with a `