fix: errors for bad keys (#3475)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-07-29 15:58:53 -07:00
1 parent bd6efaaa4e
commit 0e1f617401
10 files changed
+617 -72

No files matched your search

@@ -463,6 +463,23 @@ describe('KVStoreDriver', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
// A path walking the prototype chain is a client error, not an
// opaque 500 out of the document client.
it.each([
['incr' as const],
['decr' as const],
])('%s rejects a prototype-walking path as a 400', async (op) => {
await expect(
inCtx(() =>
target[op]({
key: 'proto-test',
pathAndAmountMap: { 'constructor.prototype.x': 1 },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(({} as Record<string, unknown>).x).toBeUndefined();
});
});
describe('expireAt / expire', () => {
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { describe, expect, it } from 'vitest';
import { TokenService } from './TokenService.js';
@@ -367,3 +368,54 @@ describe('TokenService.verify — algorithm pinning', () => {
expect(() => svc.verify('auth', token)).toThrow(/algorithm/);
});
});
describe('TokenService payload key handling', () => {
// Decompression looks every field name up in a table keyed by claim
// name. A crafted token can name a field after an `Object.prototype`
// member, which must be read as data rather than as a field definition.
const prototypeKeys = ['constructor', '__proto__', 'toString'];
/**
* Hand-mint a v2-shaped token. `jwt.sign` refuses these payloads (its own
* claim validator has the same prototype-lookup flaw), which is exactly
* why an attacker assembles the JWT by hand instead.
*/
const mintRawV2Token = (payloadJson: string, sign = true): string => {
const b64 = (s: string) => Buffer.from(s).toString('base64url');
const head = b64(
JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: 'v2' }),
);
const body = b64(payloadJson);
const sig = sign
? createHmac('sha256', V2_SECRET)
.update(`${head}.${body}`)
.digest('base64url')
: 'not-a-signature';
return `${head}.${body}.${sig}`;
};
it.each(prototypeKeys)(
'decodes an unsigned payload carrying a `%s` field',
(key) => {
const svc = createTokenService();
const token = mintRawV2Token(`{"t":"s","${key}":"x"}`, false);
const decoded = svc.decodeWithoutVerify<Record<string, unknown>>(
'auth',
token,
);
expect(decoded).toMatchObject({ type: 'session' });
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype);
},
);
it.each(prototypeKeys)(
'verifies a signed payload carrying a `%s` field',
(key) => {
const svc = createTokenService();
const token = mintRawV2Token(`{"t":"s","${key}":"x"}`);
const verified = svc.verify<Record<string, unknown>>('auth', token);
expect(verified).toMatchObject({ type: 'session' });
expect(Object.getPrototypeOf(verified)).toBe(Object.prototype);
},
);
});
+63 -37
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import jwt, { type SignOptions } from 'jsonwebtoken';
@@ -52,9 +53,32 @@ interface CompressionContext {
short_to_fullkey: Record<string, string>;
}
/**
* Table lookups keyed by a JWT field name. A decoded payload is attacker-
* shaped (`decodeWithoutVerify` runs before any signature check), and a literal
* `constructor` or `__proto__` key would otherwise resolve to an
* `Object.prototype` member and be mistaken for a field definition.
*/
const lookupOwn = <T>(table: Record<string, T>, key: string): T | undefined =>
Object.hasOwn(table, key) ? table[key] : undefined;
/** Write a key as data, so a `__proto__` field can't reassign the prototype. */
const setOwn = (
target: Record<string, unknown>,
key: string,
value: unknown,
): void => {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
};
const def = (o: Record<string, FieldInfoShorthand>): CompressionContext => {
const fullkey_to_info: Record<string, FieldInfo> = {};
for (const k in o) {
for (const k of Object.keys(o)) {
const v = o[k];
fullkey_to_info[k] = typeof v === 'string' ? { short: v } : v;
}
@@ -165,10 +189,10 @@ const COMPRESSION: Record<string, CompressionContext> = {
/**
* Thrown by `verify()` when a v1 token is presented while
* `auth.allow_v1_tokens=false`. Carries the **unverified** payload so
* the auth probe can mint a `reauth_required` response with an
* `auth_id` hint — the hint is advisory only (never trusted as
* identity), so reading it from an unsigned payload is safe.
* `auth.allow_v1_tokens=false`. Carries the **unverified** payload so the auth
* probe can mint a `reauth_required` response with an `auth_id` hint — the hint
* is advisory only (never trusted as identity), so reading it from an unsigned
* payload is safe.
*/
export class V1TokensDisabledError extends Error {
constructor(public readonly payload: Record<string, unknown>) {
@@ -270,9 +294,10 @@ export class TokenService extends PuterService {
/**
* Verify and decompress. Routes by header `kid`:
* - `kid === 'v2'` → verify against the v2 secret.
* - else → verify against the legacy secret and tag the result with
* `legacy: true`. Rejected outright if `allow_v1_tokens=false`.
*
* - `kid === 'v2'` → verify against the v2 secret.
* - Else → verify against the legacy secret and tag the result with `legacy:
* true`. Rejected outright if `allow_v1_tokens=false`.
*
* Throws on invalid signature / expired / malformed (propagates
* `jsonwebtoken`'s errors). Callers in the auth probe should catch and
@@ -332,14 +357,13 @@ export class TokenService extends PuterService {
}
/**
* Decode + decompress *without* verifying the signature. Returns
* `null` for malformed tokens. Use **only** for paths that need to
* recover advisory hints from an expired / unsignable token (e.g.,
* the logout path that wants to revoke a session row even if the
* JWT has expired since the user opened the tab). The result is
* never trusted as identity — only as a pointer for cleanup
* operations the caller would otherwise authorize via a different
* channel.
* Decode + decompress _without_ verifying the signature. Returns `null` for
* malformed tokens. Use **only** for paths that need to recover advisory
* hints from an expired / unsignable token (e.g., the logout path that
* wants to revoke a session row even if the JWT has expired since the user
* opened the tab). The result is never trusted as identity — only as a
* pointer for cleanup operations the caller would otherwise authorize via a
* different channel.
*/
decodeWithoutVerify<T = Record<string, unknown>>(
scope: string,
@@ -363,10 +387,10 @@ export class TokenService extends PuterService {
if (!context) return payload;
const { fullkey_to_info } = context;
const out: Record<string, unknown> = {};
for (const fullkey in payload) {
const info = fullkey_to_info[fullkey];
for (const fullkey of Object.keys(payload)) {
const info = lookupOwn(fullkey_to_info, fullkey);
if (!info) {
out[fullkey] = payload[fullkey];
setOwn(out, fullkey, payload[fullkey]);
continue;
}
let k = fullkey;
@@ -375,13 +399,13 @@ export class TokenService extends PuterService {
if (
info.values &&
typeof v === 'string' &&
info.values.to_short[v] !== undefined
lookupOwn(info.values.to_short, v) !== undefined
) {
v = info.values.to_short[v];
} else if (info.encode && typeof v === 'string') {
v = info.encode(v);
}
out[k] = v;
setOwn(out, k, v);
}
return out;
}
@@ -393,26 +417,28 @@ export class TokenService extends PuterService {
if (!context) return payload;
const { fullkey_to_info, short_to_fullkey } = context;
const out: Record<string, unknown> = {};
for (const short in payload) {
const fullkey = short_to_fullkey[short];
if (!fullkey) {
out[short] = payload[short];
for (const short of Object.keys(payload)) {
const fullkey = lookupOwn(short_to_fullkey, short);
const info = fullkey
? lookupOwn(fullkey_to_info, fullkey)
: undefined;
if (!fullkey || !info) {
setOwn(out, short, payload[short]);
continue;
}
const info = fullkey_to_info[fullkey];
let k = short;
let v = payload[short];
if (info.short) k = fullkey;
if (
info.values &&
typeof v === 'string' &&
info.values.to_long[v] !== undefined
lookupOwn(info.values.to_long, v) !== undefined
) {
v = info.values.to_long[v];
} else if (info.decode && typeof v === 'string') {
v = info.decode(v);
}
out[k] = v;
setOwn(out, k, v);
}
return out;
}
@@ -1,4 +1,12 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { setupTestServer } from '../../testUtil.ts';
import type { SystemKVStore } from './SystemKVStore.ts';
import { PuterServer } from '../../server.ts';
@@ -713,6 +721,153 @@ describe('SystemKVStore', () => {
});
});
describe('prototype-chain safety', () => {
const unsafeSegments = ['__proto__', 'constructor', 'prototype'];
afterEach(() => {
// Nothing a caller sends may end up on the shared prototype.
expect(
Object.getOwnPropertyNames(Object.prototype),
).not.toContain('polluted');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it.each(unsafeSegments)(
'incr rejects a path traversing `%s`',
async (segment) => {
await expect(
target.incr(
{
key: 'proto-incr',
pathAndAmountMap: {
[`${segment}.polluted.deep`]: 1,
},
},
opts,
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it.each(unsafeSegments)(
'decr rejects a path traversing `%s`',
async (segment) => {
await expect(
target.decr(
{
key: 'proto-decr',
pathAndAmountMap: {
[`${segment}.polluted.deep`]: 1,
},
},
opts,
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it.each(unsafeSegments)(
'update rejects a path traversing `%s`',
async (segment) => {
await expect(
target.update(
{
key: 'proto-update',
pathAndValueMap: {
[`${segment}.polluted.deep`]: 1,
},
},
opts,
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it.each(unsafeSegments)(
'add rejects a path traversing `%s`',
async (segment) => {
await expect(
target.add(
{
key: 'proto-add',
pathAndValueMap: {
[`${segment}.polluted.deep`]: 1,
},
},
opts,
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it.each(unsafeSegments)(
'remove rejects a path traversing `%s`',
async (segment) => {
await expect(
target.remove(
{ key: 'proto-remove', paths: [`${segment}.polluted`] },
opts,
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it('rejects an unsafe segment anywhere in the path', async () => {
await expect(
target.incr(
{
key: 'proto-deep',
pathAndAmountMap: { 'a.b.__proto__.polluted': 1 },
},
opts,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it.each(unsafeSegments)(
'set rejects a value carrying a `%s` key',
async (unsafeKey) => {
// A JSON body can carry these as real own properties even
// though an object literal in JS source cannot.
const value = JSON.parse(`{"${unsafeKey}":{"polluted":true}}`);
await expect(
target.set({ key: 'proto-set', value }, opts),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it('rejects an unsafe key nested deep inside a value', async () => {
const value = JSON.parse(
'{"a":[{"b":{"constructor":{"polluted":true}}}]}',
);
await expect(
target.set({ key: 'proto-nested', value }, opts),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an unsafe key in a batchPut item', async () => {
const value = JSON.parse('{"__proto__":{"polluted":true}}');
await expect(
target.batchPut({ items: [{ key: 'proto-batch', value }] }, opts),
).rejects.toMatchObject({ statusCode: 400 });
});
it('still allows ordinary paths and values', async () => {
await target.set(
{ key: 'proto-ok', value: { safe: { nested: true } } },
opts,
);
const stored = await target.get({ key: 'proto-ok' }, opts);
expect(stored.res).toEqual({ safe: { nested: true } });
const bumped = await target.incr(
{ key: 'proto-ok-counter', pathAndAmountMap: { 'a.b': 2 } },
opts,
);
expect(bumped.res).toMatchObject({ a: { b: 2 } });
});
});
describe('usage accounting', () => {
it('reports write usage on set and read usage on get', async () => {
const setRes = await target.set(
+102 -26
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { PuterStore } from '../types';
@@ -41,8 +42,10 @@ export interface KVUsage {
write: number;
}
/** Standard return envelope: `res` is the operation result, `usage` is the
* DynamoDB consumed capacity so callers can meter if they choose to. */
/**
* Standard return envelope: `res` is the operation result, `usage` is the
* DynamoDB consumed capacity so callers can meter if they choose to.
*/
export interface KVResult<T> {
res: T;
usage: KVUsage;
@@ -117,7 +120,73 @@ const assertKey = (key: string): void => {
}
};
const assertValueSize = (value: unknown): void => {
/**
* Object keys we refuse to store or to walk, anywhere in a value or a document
* path.
*
* `__proto__` is the prototype-pollution vector: any code that walks a
* caller-supplied path (`createPaths` below, and every future traversal) would
* step onto `Object.prototype` and write there. It also can't round-trip — the
* AWS document client unmarshalls maps with plain assignment, so a stored
* `__proto__` attribute comes back as the object's prototype instead of as
* data. `constructor`/`prototype` are the second hop of the same walk, and a
* map holding a `constructor` key fails the document client's own "is this a
* plain object" test, which surfaces as an opaque 500 rather than a client
* error.
*/
const UNSAFE_OBJECT_KEYS: ReadonlySet<string> = new Set([
'__proto__',
'constructor',
'prototype',
]);
const unsafeKeyError = (key: string, subject: string): HttpError =>
new HttpError(400, `kv: ${subject} \`${key}\` is not allowed`, {
legacyCode: 'bad_request',
});
/**
* Reject a caller-supplied document path (`a.b.c`, optionally with `[0]` list
* indexes) whose segments would walk onto the prototype chain.
*/
const assertPath = (valPath: string): void => {
if (typeof valPath !== 'string')
throw new HttpError(400, 'kv: path must be a string', {
legacyCode: 'bad_request',
});
for (const chunk of valPath.split('.')) {
const name = chunk.split(/\[\d*\]/g)[0];
if (UNSAFE_OBJECT_KEYS.has(name))
throw unsafeKeyError(name, 'path segment');
}
};
const assertPaths = (paths: string[]): void => {
for (const valPath of paths) assertPath(valPath);
};
/**
* Walk a value about to be stored and reject unsafe keys. Iterative so a deeply
* nested value can't blow the stack; own keys only, matching what the document
* client actually marshalls.
*/
const assertSafeValueKeys = (value: unknown): void => {
const stack: unknown[] = [value];
while (stack.length > 0) {
const current = stack.pop();
if (!current || typeof current !== 'object') continue;
if (Array.isArray(current)) {
stack.push(...current);
continue;
}
for (const [k, v] of Object.entries(current)) {
if (UNSAFE_OBJECT_KEYS.has(k)) throw unsafeKeyError(k, 'value key');
stack.push(v);
}
}
};
const assertValue = (value: unknown): void => {
const size = Buffer.byteLength(JSON.stringify(value ?? null), 'utf8');
if (size > MAX_VALUE_BYTES) {
throw new HttpError(
@@ -126,6 +195,7 @@ const assertValueSize = (value: unknown): void => {
{ legacyCode: 'bad_request' },
);
}
assertSafeValueKeys(value);
};
const normalizePattern = (pattern?: string): string | undefined => {
@@ -170,8 +240,8 @@ const cleanAttrName = (chunk: string): string =>
*
* Every method returns `{ res, usage }` — `res` is the operation result,
* `usage` is the DynamoDB consumed capacity split into read/write units so
* callers can meter at the driver level when needed. No metering happens
* inside the store itself.
* callers can meter at the driver level when needed. No metering happens inside
* the store itself.
*
* If `opts.actor` is omitted, operations are scoped to the system namespace.
*/
@@ -252,7 +322,7 @@ export class SystemKVStore extends PuterStore {
opts?: KVOpts,
): Promise<KVResult<boolean>> {
assertKey(key);
assertValueSize(value);
assertValue(value);
const actor = ensureActor(opts);
const namespace = getNamespace(actor, opts?.appUuid);
@@ -288,7 +358,7 @@ export class SystemKVStore extends PuterStore {
for (const item of items) {
const k = String(item.key);
assertKey(k);
assertValueSize(item.value);
assertValue(item.value);
byKey.set(k, {
key: k,
value: item.value,
@@ -619,6 +689,7 @@ export class SystemKVStore extends PuterStore {
{ legacyCode: 'bad_request' },
);
}
assertPaths(Object.keys(pathAndAmountMap));
const actor = ensureActor(opts);
const namespace = getNamespace(actor, opts?.appUuid);
@@ -732,8 +803,9 @@ export class SystemKVStore extends PuterStore {
});
}
for (const val of Object.values(pathAndValueMap)) {
assertValueSize(val);
assertValue(val);
}
assertPaths(Object.keys(pathAndValueMap));
const actor = ensureActor(opts);
const namespace = getNamespace(actor, opts?.appUuid);
@@ -800,6 +872,7 @@ export class SystemKVStore extends PuterStore {
legacyCode: 'bad_request',
});
}
assertPaths(paths);
const actor = ensureActor(opts);
const namespace = getNamespace(actor, opts?.appUuid);
@@ -878,8 +951,9 @@ export class SystemKVStore extends PuterStore {
});
}
for (const val of Object.values(pathAndValueMap)) {
assertValueSize(val);
assertValue(val);
}
assertPaths(Object.keys(pathAndValueMap));
const actor = ensureActor(opts);
const namespace = getNamespace(actor, opts?.appUuid);
@@ -1021,14 +1095,16 @@ export class SystemKVStore extends PuterStore {
/**
* Ensure each intermediate map layer exists for a set of nested paths.
* Returns write units consumed. DDB can't set nested paths on missing
* parents in one expression, so we walk the layers and
* `SET ... if_not_exists(..., {})` each one.
* parents in one expression, so we walk the layers and `SET ...
* if_not_exists(..., {})` each one.
*/
private async createPaths(
namespace: string,
key: string,
pathList: string[],
): Promise<number> {
assertPaths(pathList);
const nestedMapValue = (() => {
const valueRoot: Record<string, unknown> = {};
let hasPaths = false;
@@ -1039,12 +1115,12 @@ export class SystemKVStore extends PuterStore {
let cursor: Record<string, unknown> = valueRoot;
for (let i = 0; i < chunks.length - 1; i++) {
const chunk = chunks[i];
const existing = cursor[chunk];
if (
!existing ||
typeof existing !== 'object' ||
Array.isArray(existing)
) {
// Own properties only: an inherited hit here would mean
// walking (and then writing to) the prototype chain.
const existing = Object.hasOwn(cursor, chunk)
? cursor[chunk]
: undefined;
if (!isPlainObject(existing)) {
cursor[chunk] = {};
}
cursor = cursor[chunk] as Record<string, unknown>;
+11 -4
View File
@@ -44,29 +44,36 @@ export class IPCService extends Service {
`;
async _init () {
this.connections_ = {};
// A Map, not an object: lookups are keyed by a uuid an app sends us
// (`messageToApp`), and on a plain object `constructor`/`__proto__`
// would resolve to an `Object.prototype` member — which we would then
// both treat as a live connection and write our cached instance onto.
// Matches ProcessService's uuid registries.
this.connections_ = new Map();
}
add_connection ({ source, target }) {
const uuid = window.uuidv4();
const r_uuid = window.uuidv4();
const forward = this.connections_[uuid] = {
const forward = {
source,
target,
uuid: uuid,
reverse: r_uuid,
};
const backward = this.connections_[r_uuid] = {
const backward = {
source: target,
target: source,
uuid: r_uuid,
reverse: uuid,
};
this.connections_.set(uuid, forward);
this.connections_.set(r_uuid, backward);
return { forward, backward };
}
get_connection (uuid) {
const entry = this.connections_[uuid];
const entry = this.connections_.get(uuid);
if ( ! entry ) return;
if ( entry.object ) return entry.object;
return entry.object = new InternalConnection(entry, this.context);
+86
View File
@@ -0,0 +1,86 @@
/*
* 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 { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// `definitions.js` reaches for the class registry the GUI bundle installs in
// init_sync.js, so stub it before importing anything from that graph.
globalThis.def = globalThis.def ?? ((cls) => cls);
globalThis.use = globalThis.use ?? (() => ({}));
let IPCService;
let uuidCounter = 0;
beforeAll(async () => {
({ IPCService } = await import('./IPCService.js'));
});
const makeService = async () => {
const svc = new IPCService();
await svc.init({ services: { get: () => ({}) } });
return svc;
};
describe('IPCService connections', () => {
beforeEach(() => {
uuidCounter = 0;
globalThis.window = globalThis.window ?? {};
window.uuidv4 = () => `uuid-${++uuidCounter}`;
window.ipc_handlers = {};
});
afterEach(() => {
delete Object.prototype.object;
});
it('round-trips a registered connection by uuid', async () => {
const svc = await makeService();
const { forward, backward } = svc.add_connection({
source: 'a',
target: 'b',
});
expect(svc.get_connection(forward.uuid).target).toBe('b');
expect(svc.get_connection(backward.uuid).target).toBe('a');
});
it('caches the connection object per uuid', async () => {
const svc = await makeService();
const { forward } = svc.add_connection({ source: 'a', target: 'b' });
expect(svc.get_connection(forward.uuid)).toBe(
svc.get_connection(forward.uuid),
);
});
// The uuid comes from an app's `messageToApp` payload, so a prototype
// member name must not look like a live connection — nor become one.
it.each(['__proto__', 'constructor', 'toString'])(
'treats `%s` as an unknown connection',
async (uuid) => {
const svc = await makeService();
svc.add_connection({ source: 'a', target: 'b' });
expect(svc.get_connection(uuid)).toBeUndefined();
expect({}.object).toBeUndefined();
},
);
it('returns undefined for an unknown uuid', async () => {
const svc = await makeService();
expect(svc.get_connection('nope')).toBeUndefined();
});
});
+27 -4
View File
@@ -6,6 +6,29 @@
// Since `Symbol` is not clonable, we use a UUID to identify RPCs.
export const $SCOPE = '9a9c83a4-7897-43a0-93b9-53217b84fde6';
/**
* Copy a key onto a rebuilt object as an own data property.
*
* Both sides of this bridge rebuild objects out of values the *other*
* document sent, so `result[key] = value` is not safe: a message carrying a
* literal `__proto__` key would hit `Object.prototype`'s setter and swap the
* prototype of the object we hand to our own caller, making it look like it
* has properties it was never sent. `defineProperty` always writes data.
*
* @param {Record<string, unknown>} target
* @param {string} key
* @param {unknown} value
* @returns {void}
*/
const defineOwn = (target, key, value) => {
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
});
};
/**
* The CallbackManager is used to manage callbacks for RPCs.
* It is used by the dehydrator and hydrator to store and retrieve
@@ -59,8 +82,8 @@ export class Dehydrator {
return value.map(this.dehydrate_value_.bind(this));
} else if ( typeof value === 'object' && value !== null ) {
const result = {};
for ( const key in value ) {
result[key] = this.dehydrate_value_(value[key]);
for ( const key of Object.keys(value) ) {
defineOwn(result, key, this.dehydrate_value_(value[key]));
}
return result;
} else {
@@ -94,8 +117,8 @@ export class Hydrator {
return value.map(this.hydrate_value_.bind(this));
} else if ( typeof value === 'object' && value !== null ) {
const result = {};
for ( const key in value ) {
result[key] = this.hydrate_value_(value[key]);
for ( const key of Object.keys(value) ) {
defineOwn(result, key, this.hydrate_value_(value[key]));
}
return result;
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { CallbackManager, Dehydrator, Hydrator } from './xdrpc.js';
/**
* Build `{ [key]: value }` with `key` as a real own property — an object
* literal can't spell that for `__proto__`, but a message from the other
* document (or any JSON payload) carries it just fine.
*/
const withOwnKey = (key, value) =>
JSON.parse(`{"${key}":${JSON.stringify(value)}}`);
describe('xdrpc prototype safety', () => {
const prototypeKeys = ['__proto__', 'constructor', 'toString'];
it.each(prototypeKeys)(
'hydrate keeps a `%s` key as data on the rebuilt object',
(key) => {
const hydrator = new Hydrator({ target: { postMessage: () => {} } });
const result = hydrator.hydrate(
withOwnKey(key, { escalated: true }),
);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result.escalated).toBeUndefined();
expect(Object.hasOwn(result, key)).toBe(true);
},
);
it.each(prototypeKeys)(
'dehydrate keeps a `%s` key as data on the rebuilt object',
(key) => {
const dehydrator = new Dehydrator({
callbackManager: new CallbackManager(),
});
const result = dehydrator.dehydrate(
withOwnKey(key, { escalated: true }),
);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result.escalated).toBeUndefined();
expect(Object.hasOwn(result, key)).toBe(true);
},
);
it('leaves the shared prototype untouched', () => {
const hydrator = new Hydrator({ target: { postMessage: () => {} } });
hydrator.hydrate(withOwnKey('__proto__', { escalated: true }));
expect({}.escalated).toBeUndefined();
});
it('still round-trips ordinary values and callback stubs', () => {
const callbackManager = new CallbackManager();
const dehydrated = new Dehydrator({ callbackManager }).dehydrate({
name: 'x',
nested: { list: [1, 2], fn: () => 'called' },
});
expect(dehydrated).toMatchObject({
name: 'x',
nested: { list: [1, 2] },
});
const posted = [];
const hydrated = new Hydrator({
target: { postMessage: (msg) => posted.push(msg) },
}).hydrate(dehydrated);
expect(hydrated.name).toBe('x');
expect(hydrated.nested.list).toEqual([1, 2]);
hydrated.nested.fn('arg');
expect(posted).toHaveLength(1);
expect(posted[0].args).toEqual(['arg']);
});
});
+32
View File
@@ -195,6 +195,38 @@ export default suite('kv', {
t.assert.equal(await t.puter.kv.incr('kv-suite-incr-fresh', 3), 3);
},
'incr rejects a path that walks the prototype chain': async (t) => {
await t.assert.rejects(
() =>
t.puter.kv.incr('kv-suite-proto', {
'constructor.prototype.polluted': 1,
}),
'a prototype-walking path should be rejected',
);
await t.assert.rejects(
() =>
t.puter.kv.incr('kv-suite-proto', {
'__proto__.polluted.deep': 1,
}),
'a __proto__ path should be rejected',
);
t.assert.equal(
({} as Record<string, unknown>).polluted,
undefined,
'nothing should have landed on Object.prototype',
);
},
'set rejects a value carrying a reserved object key': async (t) => {
// A JSON body can carry `__proto__` as a real own property; an object
// literal in source cannot.
const value = JSON.parse('{"__proto__":{"polluted":true}}');
await t.assert.rejects(
() => t.puter.kv.set('kv-suite-proto-value', value),
'a value with a __proto__ key should be rejected',
);
},
'decr can drive a value negative': async (t) => {
t.assert.equal(await t.puter.kv.decr('kv-suite-decr-neg', 5), -5);
},