diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 753351a0e..9bbeec8da 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -90,6 +90,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [54, ['0059_add_card_verification.sql']], [55, ['0060_add_card_fingerprint.sql']], [56, ['0061_add_suspended_at.sql']], + [57, ['0062_blocked-app-origins.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_17.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_17.sql new file mode 100644 index 000000000..c04f0f85c --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_17.sql @@ -0,0 +1,36 @@ +-- 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 . + +-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062. +-- An app whose `index_url` host (or a request origin) matches an entry is +-- denied access to Puter resources: it cannot obtain an app token and +-- already-issued app tokens are rejected on each request. `include_subdomains +-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via +-- AppOriginBlocklistService. +-- +-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely. + +CREATE TABLE IF NOT EXISTS `blocked_app_origins` ( + `id` INT NOT NULL AUTO_INCREMENT, + `domain` VARCHAR(255) NOT NULL, + `include_subdomains` TINYINT(1) NOT NULL DEFAULT 0, + `reason` TEXT DEFAULT NULL, + `created_by` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/backend/clients/database/migrations/sqlite/0062_blocked-app-origins.sql b/src/backend/clients/database/migrations/sqlite/0062_blocked-app-origins.sql new file mode 100644 index 000000000..4aebdd869 --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0062_blocked-app-origins.sql @@ -0,0 +1,34 @@ +-- 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 . + +-- Admin-managed blocklist of app origins. An app whose `index_url` host (or +-- a request origin) matches an entry is denied access to Puter resources: +-- it cannot obtain an app token and already-issued app tokens are rejected +-- on each request. `include_subdomains = 1` also blocks every subdomain of +-- `domain`. Enforced in AuthService via AppOriginBlocklistService. + +CREATE TABLE IF NOT EXISTS `blocked_app_origins` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "domain" TEXT NOT NULL, + "include_subdomains" INTEGER NOT NULL DEFAULT 0, + "reason" TEXT DEFAULT NULL, + "created_by" TEXT DEFAULT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain` + ON `blocked_app_origins` (`domain`); diff --git a/src/backend/core/http/expressAugmentation.ts b/src/backend/core/http/expressAugmentation.ts index 9a2b3c4d4..20738974b 100644 --- a/src/backend/core/http/expressAugmentation.ts +++ b/src/backend/core/http/expressAugmentation.ts @@ -42,6 +42,13 @@ declare global { tokenAuthFailed?: boolean; + /** + * Set when a token authenticated but its app is on the origin + * blocklist. The auth probe leaves `actor` unset; gates translate + * this into a 403 `app_blocked`. + */ + appBlocked?: { reason?: string }; + requiresReauth?: { reason: 'token_v1' | 'session_revoked' | 'session_expired'; auth_id?: string; diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts index 121b68870..0c654afac 100644 --- a/src/backend/core/http/middleware/authProbe.ts +++ b/src/backend/core/http/middleware/authProbe.ts @@ -93,6 +93,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => { ); } + if (result.blocked) { + // App is on the origin blocklist: leave `actor` unset so gates + // reject. `appBlocked` lets the gate emit a clear 403 instead + // of the generic "token failed" 401. + req.appBlocked = { reason: result.blocked.reason }; + } + if (result.actor) { req.actor = result.actor; req.token = token; diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index 66a03a4c0..b71edc146 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -79,6 +79,16 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => { */ export const requireAuthGate = (): RequestHandler => { return (req, _res, next) => { + if (req.appBlocked) { + next( + new HttpError( + 403, + 'This app is not allowed to access Puter resources', + { legacyCode: 'app_blocked' }, + ), + ); + return; + } if (!req.actor) { next(rejectAuth(req)); return; diff --git a/src/backend/services/abuse/AppOriginBlocklistService.test.ts b/src/backend/services/abuse/AppOriginBlocklistService.test.ts new file mode 100644 index 000000000..9538f2dcb --- /dev/null +++ b/src/backend/services/abuse/AppOriginBlocklistService.test.ts @@ -0,0 +1,174 @@ +/** + * 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 { AppOriginBlocklistService } from './AppOriginBlocklistService.js'; + +type Row = { + domain: string; + include_subdomains: number; + reason?: string | null; +}; + +const makeService = ( + rows: Row[], + read?: () => Promise, +): { service: AppOriginBlocklistService; reads: { count: number } } => { + const reads = { count: 0 }; + const db = { + read: + read ?? + (async () => { + reads.count++; + return rows; + }), + }; + const service = new AppOriginBlocklistService( + {} as never, + { db } as never, + {} as never, + {} as never, + ); + return { service, reads }; +}; + +describe('AppOriginBlocklistService', () => { + describe('isHostBlocked — exact entries', () => { + it('matches the exact host only', async () => { + const { service } = makeService([ + { domain: 'some.evil.com', include_subdomains: 0 }, + ]); + expect(await service.isHostBlocked('some.evil.com')).toEqual({ + blocked: true, + reason: undefined, + }); + expect((await service.isHostBlocked('evil.com')).blocked).toBe( + false, + ); + expect( + (await service.isHostBlocked('x.some.evil.com')).blocked, + ).toBe(false); + }); + }); + + describe('isHostBlocked — include_subdomains entries', () => { + it('matches the apex and any subdomain, but not lookalikes', async () => { + const { service } = makeService([ + { domain: 'evil.com', include_subdomains: 1, reason: 'abuse' }, + ]); + expect(await service.isHostBlocked('evil.com')).toEqual({ + blocked: true, + reason: 'abuse', + }); + expect((await service.isHostBlocked('a.evil.com')).blocked).toBe( + true, + ); + expect((await service.isHostBlocked('a.b.evil.com')).blocked).toBe( + true, + ); + // Suffix-but-not-subdomain must NOT match. + expect((await service.isHostBlocked('notevil.com')).blocked).toBe( + false, + ); + expect((await service.isHostBlocked('evil.com.org')).blocked).toBe( + false, + ); + }); + }); + + describe('normalization', () => { + it('lowercases, strips port, and ignores empty input', async () => { + const { service } = makeService([ + { domain: 'evil.com', include_subdomains: 1 }, + ]); + expect((await service.isHostBlocked('A.EVIL.COM')).blocked).toBe( + true, + ); + expect( + (await service.isHostBlocked('a.evil.com:8080')).blocked, + ).toBe(true); + expect((await service.isHostBlocked('')).blocked).toBe(false); + }); + + it('normalizes stored entries too (uppercase/leading dot)', async () => { + const { service } = makeService([ + { domain: '.Evil.COM', include_subdomains: 1 }, + ]); + expect((await service.isHostBlocked('a.evil.com')).blocked).toBe( + true, + ); + }); + }); + + describe('isOriginBlocked', () => { + it('extracts the host from a full URL', async () => { + const { service } = makeService([ + { domain: 'evil.com', include_subdomains: 1 }, + ]); + expect( + (await service.isOriginBlocked('https://app.evil.com/path?q=1')) + .blocked, + ).toBe(true); + expect( + (await service.isOriginBlocked('https://good.com/')).blocked, + ).toBe(false); + }); + + it('accepts a scheme-less origin', async () => { + const { service } = makeService([ + { domain: 'evil.com', include_subdomains: 0 }, + ]); + expect((await service.isOriginBlocked('evil.com')).blocked).toBe( + true, + ); + }); + }); + + describe('caching', () => { + it('reuses the cached snapshot within the TTL', async () => { + const { service, reads } = makeService([ + { domain: 'evil.com', include_subdomains: 0 }, + ]); + await service.isHostBlocked('evil.com'); + await service.isHostBlocked('evil.com'); + expect(reads.count).toBe(1); + }); + + it('reloads after invalidate()', async () => { + const { service, reads } = makeService([ + { domain: 'evil.com', include_subdomains: 0 }, + ]); + await service.isHostBlocked('evil.com'); + service.invalidate(); + await service.isHostBlocked('evil.com'); + expect(reads.count).toBe(2); + }); + }); + + describe('resilience', () => { + it('fails open (not blocked) when the DB read throws', async () => { + const { service } = makeService([], async () => { + throw new Error('no such table: blocked_app_origins'); + }); + expect((await service.isHostBlocked('evil.com')).blocked).toBe( + false, + ); + }); + }); +}); diff --git a/src/backend/services/abuse/AppOriginBlocklistService.ts b/src/backend/services/abuse/AppOriginBlocklistService.ts new file mode 100644 index 000000000..b42af2050 --- /dev/null +++ b/src/backend/services/abuse/AppOriginBlocklistService.ts @@ -0,0 +1,172 @@ +/** + * 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 { PuterService } from '../types.js'; + +export interface BlockMatch { + blocked: boolean; + reason?: string; +} + +interface BlocklistEntry { + domain: string; + includeSubdomains: boolean; + reason: string | null; +} + +const NOT_BLOCKED: BlockMatch = { blocked: false }; + +/** + * In-memory, TTL-cached view of the `blocked_app_origins` table. + * + * Admins manage the table (the admin extension writes it directly, mirroring + * how it writes the `user` table for suspend). This service answers the hot + * "is this app/origin blocked?" question from a cached snapshot so the + * per-request app-token validation path never hits the DB. + * + * Consistency: the cache refreshes lazily after {@link CACHE_TTL_MS}. Because + * each worker process holds its own cache, a freshly-added block can take up + * to one TTL to take effect across the fleet — acceptable for an + * admin-initiated block. + */ +export class AppOriginBlocklistService extends PuterService { + private static readonly CACHE_TTL_MS = 30_000; + + #entries: BlocklistEntry[] = []; + #loadedAt = 0; + #inflight: Promise | null = null; + + /** + * Decide whether a bare host (already without scheme/path) is blocked. + * Exact entries match the host verbatim; `include_subdomains` entries + * also match any subdomain of `domain`. + */ + async isHostBlocked(host: string): Promise { + const normalized = normalizeHost(host); + if (!normalized) return NOT_BLOCKED; + + await this.#ensureFresh(); + for (const entry of this.#entries) { + const matches = entry.includeSubdomains + ? normalized === entry.domain || + normalized.endsWith(`.${entry.domain}`) + : normalized === entry.domain; + if (matches) { + return { + blocked: true, + reason: entry.reason ?? undefined, + }; + } + } + return NOT_BLOCKED; + } + + /** + * Decide whether an origin/URL is blocked by extracting its host. Accepts + * full URLs (`https://app.example.com/path`) and bare hosts alike. + */ + async isOriginBlocked(origin: string): Promise { + return this.isHostBlocked(hostFromOrigin(origin)); + } + + /** Drop the cached snapshot so the next query reloads from the DB. */ + invalidate(): void { + this.#loadedAt = 0; + } + + async #ensureFresh(): Promise { + const age = Date.now() - this.#loadedAt; + if ( + this.#loadedAt !== 0 && + age < AppOriginBlocklistService.CACHE_TTL_MS + ) { + return; + } + // Single-flight: concurrent callers share one reload. + if (!this.#inflight) { + this.#inflight = this.#reload().finally(() => { + this.#inflight = null; + }); + } + await this.#inflight; + } + + async #reload(): Promise { + try { + const rows = (await this.clients.db.read( + 'SELECT `domain`, `include_subdomains`, `reason` FROM `blocked_app_origins`', + )) as Array>; + this.#entries = rows + .map((row) => { + const domain = normalizeHost(String(row.domain ?? '')); + if (!domain) return null; + return { + domain, + includeSubdomains: Boolean( + Number(row.include_subdomains ?? 0), + ), + reason: row.reason == null ? null : String(row.reason), + } satisfies BlocklistEntry; + }) + .filter((e): e is BlocklistEntry => e !== null); + this.#loadedAt = Date.now(); + } catch (e) { + // Never let a transient DB error turn into a request-blocking + // throw on the auth hot path. Keep serving the previous snapshot; + // a missing table (fresh dev DB pre-migration) yields an empty + // blocklist, which is the safe-open default. + console.warn( + '[app-origin-blocklist] reload failed:', + (e as Error)?.message ?? e, + ); + if (this.#loadedAt === 0) { + this.#entries = []; + this.#loadedAt = Date.now(); + } + } + } +} + +/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */ +const normalizeHost = (host: string): string => { + let h = (host ?? '').trim().toLowerCase(); + if (!h) return ''; + h = h.replace(/^\./, ''); + // Strip a trailing :port (IPv6 literals are not app origins, so the + // simple split is safe here). + const colon = h.indexOf(':'); + if (colon !== -1) h = h.slice(0, colon); + return h; +}; + +/** Extract the host from a full URL, falling back to treating input as a host. */ +const hostFromOrigin = (origin: string): string => { + const raw = (origin ?? '').trim(); + if (!raw) return ''; + try { + return new URL(raw).hostname; + } catch { + // Not a parseable URL — maybe a scheme-less origin or bare host. + try { + return new URL(`https://${raw}`).hostname; + } catch { + return raw; + } + } +}; diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 323fc3b79..824334b33 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -1348,6 +1348,85 @@ describe('AuthService (integration)', () => { }); }); + describe('app origin blocklist enforcement', () => { + // The blocklist service caches with a TTL, so seed the row then + // invalidate the in-memory snapshot to force a reload for the test. + const blockOrigin = async ( + domain: string, + includeSubdomains = false, + ) => { + await server.clients.db.write( + 'INSERT INTO `blocked_app_origins` (`domain`, `include_subdomains`) VALUES (?, ?)', + [domain, includeSubdomains ? 1 : 0], + ); + ( + server.services.appOriginBlocklist as { + invalidate: () => void; + } + ).invalidate(); + }; + + it('appUidFromOrigin throws 403 app_blocked for a blocked exact host', async () => { + const host = `blocked-${uuidv4()}.example.com`; + await blockOrigin(host); + await expect( + authService.appUidFromOrigin(`https://${host}/`), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'app_blocked', + }); + }); + + it('appUidFromOrigin throws for a subdomain of an include_subdomains entry', async () => { + const apex = `evil-${uuidv4()}.example.com`; + await blockOrigin(apex, true); + await expect( + authService.appUidFromOrigin(`https://app.${apex}/`), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'app_blocked', + }); + }); + + it('appUidFromOrigin still resolves an unrelated origin', async () => { + const uid = await authService.appUidFromOrigin( + `https://fine-${uuidv4()}.example.com/`, + ); + expect(uid).toMatch(/^app-/); + }); + + it('rejects an already-issued app token once its origin is blocked', async () => { + const user = await makeUser(); + const host = `late-block-${uuidv4()}.example.com`; + const appUid = `app-${uuidv4()}`; + // App row carries the to-be-blocked host as its index_url. + 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 appToken = await authService.getUserAppToken( + { + user: { + id: user.id, + uuid: user.uuid, + username: user.username, + }, + } as Actor, + appUid, + ); + + // Before blocking the token authenticates normally. + const ok = await authService.authenticate(appToken); + expect(ok.actor?.app?.uid).toBe(appUid); + + // After blocking the same token is rejected with the blocked signal. + await blockOrigin(host); + const blocked = await authService.authenticate(appToken); + expect(blocked.actor).toBeUndefined(); + expect(blocked.blocked).toBeTruthy(); + }); + }); + describe('getUserAppToken', () => { it('throws 403 when actor has no user', async () => { await expect( diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index ffb11ee96..cf9c34b06 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -50,6 +50,13 @@ export interface AuthResult { actor?: Actor; reauth?: { reason: ReauthReason; auth_id?: string }; invalid?: true; + /** + * The token authenticated, but its app is on the origin blocklist. The + * auth probe surfaces this as `req.appBlocked`; gates translate it to a + * 403 `app_blocked`. Distinct from `invalid` so the client sees a clear + * "app blocked" error rather than a generic auth failure. + */ + blocked?: { reason?: string }; } /** @@ -891,6 +898,19 @@ export class AuthService extends PuterService { const event = { origin: aliased }; await this.clients.event?.emitAndWait('app.from-origin', event, {}); + // Blocked origins can't acquire an app token (or have one minted / + // checked / granted), so the app loses every path to Puter resources. + const block = await this.services.appOriginBlocklist.isOriginBlocked( + event.origin, + ); + if (block.blocked) { + throw new HttpError( + 403, + 'This app is not allowed to access Puter resources', + { legacyCode: 'app_blocked' }, + ); + } + const canonicalUid = await this.#findCanonicalAppUidForOrigin( event.origin, ); @@ -1784,6 +1804,21 @@ 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 } }; + } + } + let rawRow: SessionRow | null = null; if (decoded.session_uid) { rawRow = (await this.stores.session.getByUuidAny( diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index 5545e4a47..a33ec37a2 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -18,6 +18,7 @@ */ import { ACLService } from './acl/ACLService'; +import { AppOriginBlocklistService } from './abuse/AppOriginBlocklistService'; import { AppPermissionService } from './apps/AppPermissionService'; import { RecommendedAppsService } from './apps/RecommendedAppsService'; import { SuggestedAppsService } from './apps/SuggestedAppsService'; @@ -47,6 +48,7 @@ import type { IPuterServiceRegistry } from './types'; declare module './types' { interface IPuterServiceInstances { metering: MeteringService; + appOriginBlocklist: AppOriginBlocklistService; permission: PermissionService; acl: ACLService; token: TokenService; @@ -77,6 +79,10 @@ declare module './types' { // BroadcastService is independent — only needs the event client. export const puterServices = { metering: MeteringService, + // Declared before `auth` so AuthService sees it as a prior peer — it + // queries the blocklist on app-token acquisition and per-request app + // token validation. + appOriginBlocklist: AppOriginBlocklistService, permission: PermissionService, acl: ACLService, token: TokenService,