add ability to suspend apps (#3322)

This commit is contained in:
Neal Shah
2026-06-29 13:06:08 -04:00
committed by GitHub
parent b9866ddbeb
commit 2bddebd576
11 changed files with 561 additions and 0 deletions
@@ -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 {
@@ -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 <https://www.gnu.org/licenses/>.
-- 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;
@@ -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 <https://www.gnu.org/licenses/>.
-- 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`);
@@ -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;
@@ -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;
+10
View File
@@ -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;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<unknown>,
): { 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,
);
});
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<void> | 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<BlockMatch> {
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<BlockMatch> {
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<void> {
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<void> {
try {
const rows = (await this.clients.db.read(
'SELECT `domain`, `include_subdomains`, `reason` FROM `blocked_app_origins`',
)) as Array<Record<string, unknown>>;
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;
}
}
};
@@ -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(
+35
View File
@@ -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(
+6
View File
@@ -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,