sec: misc fable hardening (#3244)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

* sec: misc fable hardening

* more fixes

* more fixes

* fix: cors issue
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-06-10 11:19:41 -07:00
1 parent bb202be12c
commit 240a733285
36 files changed
+1567 -116

No files matched your search

+6 -4
View File
@@ -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"
@@ -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 () => {
+69 -23
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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({});
}
@@ -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,
@@ -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<SignedWriteRequest[]>({
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<CompleteWriteRequest[]>({
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!;
+7 -1
View File
@@ -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)
@@ -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)
+21 -1
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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-<uuid>` 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' });
}
@@ -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).
+24 -14
View File
@@ -66,6 +66,22 @@ export type RoutePath = string | RegExp | Array<string | RegExp>;
* 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<string, number>;
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<string, number>;
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
+64 -1
View File
@@ -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 `<gui origin>/builtin/<name>` (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 `<gui origin>/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() {
@@ -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();
+1
View File
@@ -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"
+16 -4
View File
@@ -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
+18
View File
@@ -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<void> {
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 } = {},
@@ -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<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[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<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[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<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[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<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[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<Record<string, unknown>>('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/);
});
});
+39
View File
@@ -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<string, unknown>;
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<string, unknown>;
const decompressed = this.#decompressPayload(
context,
+83
View File
@@ -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) => ({
@@ -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<string, boolean>(),
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,
);
});
});
});
@@ -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:<uuid>`). */
async #bumpUserCacheGeneration(userUuid: string): Promise<void> {
await this.stores.permission.bumpCacheGeneration(`user:${userUuid}`);
}
/** Bump an app-under-user holder (`app-under-user:<uuid>:<appUid>`). */
async #bumpAppUnderUserCacheGeneration(
userUuid: string,
appUid: string,
permission: string,
): Promise<void> {
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:<uuid>` cache must be orphaned.
*/
async #bumpGroupMembersCacheGeneration(groupUid: string): Promise<void> {
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<void> {
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 ----------------------------------------------------
+24
View File
@@ -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,
@@ -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).
+24
View File
@@ -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<number | null> {
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,
+16
View File
@@ -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<string[]> {
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 -------------------------------------------------------
/**
@@ -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<number> {
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<void> {
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<Map<string, boolean>> {
const out = new Map<string, boolean>();
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<string | null> = [];
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<void> {
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,
+7 -5
View File
@@ -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). */
+119
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
);
}
});
});
+30 -3
View File
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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', {
@@ -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 <https://www.gnu.org/licenses/>.
*/
import type { Response } from 'express';
import { describe, expect, it } from 'vitest';
import { applyInlineContentSecurity } from './inlineContentSecurity.js';
const makeRes = () => {
const headers: Record<string, string> = {};
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();
});
});
+51
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 `<script>` runs when navigated to directly; HTML
// obviously does. Served inline on an api/file origin, that's stored XSS.
const ACTIVE_DOCUMENT_CONTENT_TYPE =
/^(text\/html|image\/svg\+xml|application\/xhtml\+xml|application\/xml|text\/xml)\b/i;
/**
* For file-serving endpoints that echo an uploader-controlled content type
* inline: when the type is one a browser executes as a document, apply the
* same sandbox CSP used for app icons so embedded scripts can't run with
* the serving origin. No-op for inert types (images, audio, octet-stream),
* so `<img>`/download/range consumers are unaffected.
*
* Returns true if the sandbox header was applied (useful for tests/logging).
*/
export function applyInlineContentSecurity(
res: Response,
contentType: string | null | undefined,
): boolean {
if (contentType && ACTIVE_DOCUMENT_CONTENT_TYPE.test(contentType)) {
res.setHeader(
'Content-Security-Policy',
"default-src 'none'; sandbox;",
);
res.setHeader('X-Content-Type-Options', 'nosniff');
return true;
}
return false;
}
+75 -2
View File
@@ -17,8 +17,40 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { isPublicResolvedAddress, validateUrlNoIP } from './secureHttp.js';
import { describe, expect, it, vi } from 'vitest';
import { configContainer } from '../exports.js';
import {
isPublicResolvedAddress,
secureFetch,
validateUrlNoIP,
} from './secureHttp.js';
vi.mock('node:dns', async importOriginal => {
const actual = await importOriginal<typeof import('node:dns')>();
const PRIVATE_HOSTS: Record<string, string> = {
'resolves-private.test': '10.0.0.1',
'resolves-metadata.test': '169.254.169.254',
};
return {
...actual,
lookup: ((hostname: string, options: unknown, callback: unknown) => {
const cb = callback as (
err: Error | null,
addresses?: { address: string; family: number }[],
) => void;
const addr = PRIVATE_HOSTS[hostname];
if (addr) {
cb(null, [{ address: addr, family: 4 }]);
return;
}
(actual.lookup as (...args: unknown[]) => void)(
hostname,
options,
callback,
);
}) as typeof actual.lookup,
};
});
describe('secureHttp URL validation', () => {
it('rejects raw IP and localhost URLs before fetching', () => {
@@ -43,6 +75,47 @@ describe('secureHttp resolved address validation', () => {
}
});
it('refuses to connect when a hostname resolves to a private or reserved address', async () => {
// Regression test: the resolved-address check must run on the
// connection itself, not just exist as a helper — see guardedLookup.
for (const url of [
'http://resolves-private.test/',
'http://resolves-metadata.test/',
]) {
await expect(secureFetch(url)).rejects.toSatisfy((e: unknown) => {
const err = e as Error & { cause?: Error };
return /private or reserved/.test(
err.cause?.message ?? err.message,
);
});
}
});
it('does not apply the connect-time guard when routed through the CORS proxy', async () => {
// The proxy is admin-trusted config and may legitimately sit on a
// private address; the user-supplied host is resolved by the proxy
// worker, not locally. Point the proxy at a host our DNS mock
// resolves to a private address: if the guard were (incorrectly)
// applied, we'd see the SSRF refusal — instead the connection must
// fail with an ordinary DNS error from the real resolver.
configContainer.secureCorsProxy = {
url: 'http://resolves-private.test/?url=',
secret: 'test-secret',
};
try {
await expect(
secureFetch('http://some-user-supplied-host.test/'),
).rejects.toSatisfy((e: unknown) => {
const err = e as Error & { cause?: Error };
return !/private or reserved/.test(
err.cause?.message ?? err.message,
);
});
} finally {
delete configContainer.secureCorsProxy;
}
});
it('rejects private, link-local, loopback, mapped, and reserved addresses', () => {
for (const address of [
'0.0.0.0',
+70 -4
View File
@@ -17,7 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
import net, { BlockList } from 'node:net';
import { Agent as UndiciAgent, fetch as undiciFetch } from 'undici';
import { HttpError } from '../core/http/HttpError.js';
import { configContainer } from '../exports.js';
import type { ISecureCorsProxyConfig } from '../types.js';
@@ -101,6 +103,57 @@ export function isPublicResolvedAddress(address: string): boolean {
return !BLOCKED_RESOLVED_IPS.check(address, family === 6 ? 'ipv6' : 'ipv4');
}
// Connect-time DNS guard: every address a hostname resolves to must be
// public, and the check runs on the resolution the socket actually uses —
// a validate-then-fetch design re-resolves at connect time, so checking
// here (rather than before fetch) is what defeats DNS rebinding.
const guardedLookup: net.LookupFunction = (hostname, options, callback) => {
// `net`'s lookup hook can be handed `options` as either an object or a
// bare address-family number. Normalize so we can both resolve all
// addresses and report errors back in the arity the caller expects:
// `(err, addresses[])` when `all` was requested, `(err, address, family)`
// otherwise.
const opts = typeof options === 'number' ? { family: options } : options;
const wantAll = opts.all === true;
const fail = (err: Error) => {
if (wantAll) {
callback(err, []);
} else {
callback(err, '', 0);
}
};
dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
if (err) {
fail(err);
return;
}
const list = addresses as LookupAddress[];
if (
list.length === 0 ||
list.some((a) => !isPublicResolvedAddress(a.address))
) {
fail(
Object.assign(
new Error(
`refusing to connect: ${hostname} resolves to a private or reserved address`,
),
{ code: 'ERR_SSRF_BLOCKED' },
),
);
return;
}
if (wantAll) {
callback(null, list);
} else {
callback(null, list[0].address, list[0].family);
}
});
};
const ssrfGuardDispatcher = new UndiciAgent({
connect: { lookup: guardedLookup },
});
function proxyConfig(): ISecureCorsProxyConfig | undefined {
const cfg = configContainer.secureCorsProxy;
if (cfg?.url && cfg?.secret) return cfg;
@@ -117,8 +170,10 @@ interface SecureFetchInit extends Omit<RequestInit, 'redirect'> {
* • Rejects raw-IP / localhost hosts via {@link validateUrlNoIP}.
* • Forces `redirect: 'manual'` and rejects any 3xx response so a permissive
* target can't bounce us onto an internal endpoint.
* • Resolves DNS through Cloudflare's 1.1.1.3 (malware-filtered) resolver
* and rejects private/reserved answers before connecting.
* • Re-checks DNS at connect time: every address the socket actually
* connects to must pass {@link isPublicResolvedAddress}, so a hostname
* resolving to a private/reserved address (including via DNS rebinding)
* is rejected.
* • If `config.secureCorsProxy` is set AND the URL isn't a `data:` URI,
* rewrites the request through the configured signed Cloudflare Worker
* proxy (adds `x-cors-proxy-auth-secret`).
@@ -149,12 +204,23 @@ export async function secureFetch(
}
}
// The connect-time DNS guard only applies to direct fetches. When the
// request is rewritten through the CORS proxy, the only host resolved
// locally is the proxy itself — admin-trusted config that may
// legitimately sit on a private address — and the user-supplied host
// is resolved by the proxy worker from Cloudflare's network, where
// this deployment's private ranges aren't reachable.
const proxied = finalUrl !== url;
const { skipProxy: _skip, ...rest } = init;
const response = await fetch(finalUrl, {
// lib.dom and undici fetch types are structurally equivalent here but
// nominally distinct; cast across the boundary.
const response = (await undiciFetch(finalUrl, {
...rest,
headers,
redirect: 'manual',
});
...(proxied ? {} : { dispatcher: ssrfGuardDispatcher }),
} as unknown as Parameters<typeof undiciFetch>[1])) as unknown as Response;
if (response.status >= 300 && response.status < 400) {
throw new HttpError(
+9
View File
@@ -91,6 +91,15 @@ const ipc_listener = async (event, handled) => {
return handled.resolve(false);
}
// The sender must be the iframe that owns this appInstanceID — instance
// IDs are disclosed to other apps (e.g. via `messageToApp`), so knowing
// one must not be enough to act as that app.
const owner_iframe = window.iframe_for_app_instance(event.data.appInstanceID);
if ( ! owner_iframe || event.source !== owner_iframe.contentWindow ) {
console.error('appInstanceID does not match message source');
return handled.resolve(false);
}
handled.resolve(true);
const $el_parent_window = $(window.window_for_app_instance(event.data.appInstanceID));
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "puter",
"version": "2.5.1",
"version": "2.5.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "puter",
"version": "2.5.1",
"version": "2.5.3",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.1.92",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@heyputer/puter.js",
"version": "2.5.1",
"version": "2.5.3",
"description": "Puter.js - A JavaScript library for interacting with Puter services.",
"homepage": "https://docs.puter.com",
"main": "src/index.js",
@@ -41,7 +41,7 @@
"prepublishOnly": "npm run build && mv dist/puter.js dist/puter.cjs && npm version patch"
},
"author": "Puter Technologies Inc.",
"license": "AGPL-3.0-only",
"license": "Apache-2.0",
"devDependencies": {
"@playwright/test": "^1.49.0",
"concurrently": "^8.2.2",
+10 -1
View File
@@ -997,6 +997,15 @@ const puterInit = function () {
_silentMigrateV1Token = async function (v1Token) {
if (!v1Token) return false;
try {
// The `puter_token_v2` companion cookie set by this
// endpoint is only consumed by same-origin requests to
// the GUI origin, and the main domain doesn't serve
// credentialed CORS — `include` from a third-party app
// origin fails the preflight outright. Only ask for
// credentials when we're actually on the GUI origin;
// everywhere else the JSON token response is all we need.
const sameOrigin =
globalThis.location?.origin === this.defaultGUIOrigin;
const resp = await fetch(
`${this.defaultGUIOrigin}/auth/migrate-token`,
{
@@ -1005,7 +1014,7 @@ const puterInit = function () {
Authorization: `Bearer ${v1Token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
credentials: sameOrigin ? 'include' : 'omit',
body: JSON.stringify({}),
},
);