mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 08:09:15 +00:00
sec: misc fable hardening (#3244)
* sec: misc fable hardening * more fixes * more fixes * fix: cors issue
This commit is contained in:
1 parent
bb202be12c
commit
240a733285
36 files changed
+1567
-116
No files matched your search
Generated
+6
-4
@@ -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 () => {
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({}),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user