Skip to content

Commit 1c98ce0

Browse files
authored
feat(errors): self censoring stack frames (#2928)
Closes: #XXXX Refs: #XXXX ## Description Introduces `hideAndHardenFunction` for functions to opt out of having their frames be visible in abbreviated stacks. See changes to `lockdown.md` for more. ```js import { hideAndHardenFunction } from '@endo/errors'; export const foo() = {...stuff...}; hideAndHardenFunction(foo); console.log(foo.name); // '__HIDE_foo' ``` If a function `foo` is first frozen with `hideAndHardenFunction(foo)` rather than `freeze(foo)` or `harden(foo)`, then `foo.name` is changed from `'foo'` to `'__HIDE_foo'`. When `stackFiltering: 'concise'` or `stackFiltering: 'omit-frames'`, then (currently only on v8), the stack frames for function whose `name` begins with `'__HIDE_'` are omitted from the stacks reported by our causal console. See ### Security Considerations By allowing functions to opt into this themselves, a sneaky programmer could use this to hide their attack functions from appearing in frames of abbreviated stacks. However, more security conscious scenarios should consider `stackFiltering: 'verbose'` or `stackFiltering: 'shorten-paths'` anyway, which do not drop frames. Nevertheless, this security hazard is real. ### Scaling Considerations none ### Documentation Considerations `hideAndHardenFunction` has a good doc-comment, so presumably that will appear in docs generated from doc-comments. ### Testing Considerations As always, test that are about what should appear in stacks are hard to write regression tests for, since their details depend on, for example, platform and precise line and column numbers. This would make for goldens that are *far* to fragile. Instead, I manually tested these, and I captured the results of manual tests of `deep-send.test.js` in `lockdown.md`. ### Compatibility Considerations Production code should never depend on the contents of error messages or error stacks. However, tests can. We know of goldens for error messages, but none of error stacks. This PR only affects error stacks, so we do not expect any compat problems even with golden tests. ### Upgrade Considerations none
1 parent 87530c5 commit 1c98ce0

19 files changed

+332
-154
lines changed

packages/common/apply-labeling-error.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { hideAndHardenFunction } from '@endo/errors';
12
import { E } from '@endo/eventual-send';
23
import { isPromise } from '@endo/promise-kit';
34
import { throwLabeled } from './throw-labeled.js';
@@ -57,4 +58,4 @@ export const applyLabelingError = (func, args, label = undefined) => {
5758
return result;
5859
}
5960
};
60-
harden(applyLabelingError);
61+
hideAndHardenFunction(applyLabelingError);

packages/common/throw-labeled.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { X, makeError, annotateError } from '@endo/errors';
1+
import {
2+
X,
3+
makeError,
4+
annotateError,
5+
hideAndHardenFunction,
6+
} from '@endo/errors';
27

38
/**
49
* Given an error `innerErr` and a `label`, throws a similar
@@ -28,4 +33,4 @@ export const throwLabeled = (
2833
annotateError(outerErr, X`Caused by ${innerErr}`);
2934
throw outerErr;
3035
};
31-
harden(throwLabeled);
36+
hideAndHardenFunction(throwLabeled);

packages/errors/NEWS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
User-visible changes in `@endo/errors`:
22

3+
# Next release
4+
5+
- `hideAndHardenFunction` - If a function `foo` is first frozen with `hideAndHardenFunction(foo)` rather than `freeze(foo)` or `harden(foo)`, then `foo.name` is changed from `'foo'` to `'__HIDE_foo'`. When `stackFiltering: 'concise'` or `stackFiltering: 'omit-frames'`, then (currently only on v8), the stack frames for that function are omitted from the stacks reported by our causal console.
6+
37
# v1.1.0 (2024-02-22)
48

59
- `AggegateError` support

packages/errors/index.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
// The assertions re-exported here are defined in
1212
// https://github.com/endojs/endo/blob/HEAD/packages/ses/src/error/assert.js
1313

14+
const { defineProperty } = Object;
15+
1416
const globalAssert = globalThis.assert;
1517

1618
if (globalAssert === undefined) {
@@ -87,3 +89,30 @@ export {
8789
throwRedacted as Fail,
8890
note as annotateError,
8991
};
92+
93+
/**
94+
* `stackFiltering: 'omit-frames'` and `stackFiltering: 'concise'` omit frames
95+
* not only of "obvious" infrastructure functions, but also of functions
96+
* whose `name` property begins with `'__HIDE_'`. (Note: currently
97+
* these options only work on v8.)
98+
*
99+
* Given that `func` is not yet frozen, then `hideAndHardenFunction(func)`
100+
* will prifix `func.name` with an additional `'__HIDE_'`, so that under
101+
* those stack filtering options, frames for calls to such functions are
102+
* not reported.
103+
*
104+
* Then the function is hardened and returned. Thus, you can say
105+
* `hideAndHardenFunction(func)` where you would normally first say
106+
* `harden(func)`.
107+
*
108+
* @param {Function} func
109+
*/
110+
export const hideAndHardenFunction = func => {
111+
typeof func === 'function' || throwRedacted`${func} must be a function`;
112+
const { name } = func;
113+
defineProperty(func, 'name', {
114+
// Use `String` in case `name` is a symbol.
115+
value: `__HIDE_${String(name)}`,
116+
});
117+
return harden(func);
118+
};

packages/errors/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"ses": "workspace:^"
3939
},
4040
"devDependencies": {
41+
"@endo/eventual-send": "workspace:^",
4142
"@endo/lockdown": "workspace:^",
4243
"@endo/ses-ava": "workspace:^",
4344
"ava": "^6.4.1",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// This file is not really useful as an
2+
// automated test. Rather, its purpose is just to run it to see what a
3+
// deep stack looks like.
4+
// [lockdown.md](https://github.com/endojs/endo/blob/master/packages/ses/docs/lockdown.md)
5+
// shows the output for `errorTaming: 'safe'` and all variations of
6+
// `stackFiltering.
7+
8+
// Note: importing `commit.js` rather than `commit-debug.js` so that
9+
// `errorTaming` is subject to variation by environment variables. If no
10+
// LOCKDOWN_ERROR_TAMING is set, then it defaults to `'safe'`, whereas
11+
// `commit.js` sets it to `'unsafe'`.
12+
import '@endo/lockdown/commit.js';
13+
import '@endo/eventual-send/shim.js';
14+
import test from 'ava';
15+
16+
import { E } from '@endo/eventual-send';
17+
import { Fail, hideAndHardenFunction, q } from '../index.js';
18+
19+
const { freeze } = Object;
20+
21+
const carol = freeze({
22+
// Throw an error with unredacted and redacted contents.
23+
bar: () => Fail`${q('blue')} is not ${42}`,
24+
});
25+
26+
const bob = freeze({
27+
foo: carolP => E(carolP).bar(),
28+
});
29+
30+
const alice = freeze({
31+
test: () => E(bob).foo(carol),
32+
});
33+
34+
const goAskAlice = () => alice.test();
35+
hideAndHardenFunction(goAskAlice);
36+
37+
test('deep-send demo test', t => {
38+
const p = goAskAlice();
39+
return p.catch(reason => {
40+
t.true(reason instanceof Error);
41+
t.log('possibly redacted message:', reason.message);
42+
t.log('possibly redacted stack:', JSON.stringify(reason.stack));
43+
t.log('expected failure:', reason);
44+
});
45+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// This file is not really useful as an
2+
// automated test. Rather, its purpose is just to run it to see what a
3+
// deep stack looks like.
4+
5+
import '@endo/lockdown/commit-debug.js';
6+
import '@endo/eventual-send/shim.js';
7+
import test from 'ava';
8+
9+
import { E } from '@endo/eventual-send';
10+
11+
test('deep-stacks demo test', t => {
12+
/** @type {any} */
13+
let r;
14+
const p = new Promise(res => (r = res));
15+
const q = E.when(p, v1 => E.when(v1 + 1, v2 => assert.equal(v2, 22)));
16+
r(33);
17+
return q.catch(reason => {
18+
t.assert(reason instanceof Error);
19+
t.log('expected failure', reason);
20+
});
21+
});

packages/eventual-send/test/deep-send.test.js

Lines changed: 0 additions & 40 deletions
This file was deleted.

packages/eventual-send/test/deep-stacks.test.js

Lines changed: 0 additions & 29 deletions
This file was deleted.

packages/pass-style/test/message-breakpoints-demo.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import test from '@endo/ses-ava/prepare-endo.js';
44
import { E } from '@endo/eventual-send';
55
import { Far } from '../src/make-far.js';
66

7-
// Example from test-deep-send.js in @endo/eventual-send
7+
// Example from test-deep-send.js in @endo/errors
88

99
const carol = Far('Carol', {
1010
bar: () => console.log('Wut?'),

0 commit comments

Comments
 (0)