mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 20:55:55 +00:00
add ability to suspend apps (#3322)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user