fix(browser): cap the saved values a scenario keeps

A scenario keeps at most 4,000,000 characters of saved values at once,
counting the current value of each name and the redaction spellings
secret saves keep, and a save that would exceed it fails its step before
anything is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-26 09:37:47 -07:00
1 parent 22ac315c34
commit d39f386d20
5 files changed
+90 -3

No files matched your search

+3
View File
@@ -516,6 +516,9 @@ id the application assigns:
the same name replaces the value. An integer past 2^53, which JSON parsing
would round to another number, and a number that is not finite, such as
`1e400`, fail the step that saves it.
- A scenario keeps at most 4,000,000 characters of saved values at once,
counting the redaction spellings of values saved as secret, and a save past
that fails its step before the value is kept.
- A value saved with `secret: true` is redacted however short, as a declared
secret is, as `[REDACTED:page:<step_id>.<name>]`, from records made after it
is saved. The
@@ -41,6 +41,9 @@ values.
local storage entry. The encoded value and parts read from the page are
redacted as page values are. A username with a colon fails the step.
- Validation refuses a saved value no earlier step saves.
- A scenario keeps at most 4,000,000 characters of saved values at once, counting
the redaction spellings of values saved as secret; a save past that fails its
step before the value is kept.
- A value saved with `secret: true` is redacted from records made after it is
saved. The environment build warns about saved values named like credentials
that are not marked secret (`credential-named-saved-values`).
+36 -2
View File
@@ -19,6 +19,9 @@ const REDACTION_PREFIX = "[REDACTED:";
/** Page values shorter than this are not redacted: redacting them would rewrite ids, ports and hosts throughout the capture. */
export const MIN_REDACTED_PAGE_VALUE_LENGTH = 8;
/** The most saved-value text a scenario keeps at once, with the redaction spellings of values saved as secret: as much as one saved response can carry. */
export const MAX_RETAINED_SAVED_CHARACTERS = 4_000_000;
const encodedSecretValues = (secret: string): ReadonlySet<string> =>
new Set([
encodeURIComponent(secret),
@@ -75,6 +78,17 @@ const secretPatterns = (
});
};
/** The code units a secret's redaction spellings keep, each distinct spelling once. */
const spellingUnits = (secret: string): number =>
[
...new Set(
[
...secretPatterns(secret, "text"),
...secretPatterns(secret, "url"),
].flatMap(({ text, searchText }) => [text, searchText]),
),
].reduce((units, spelling) => units + spelling.length, 0);
const declaredPatterns = (
values: ReadonlyMap<string, string>,
spellings: SecretSpellings,
@@ -331,15 +345,35 @@ export class BrowserScenarioSecrets {
/** Values steps saved from JSON responses, by name, as strings or numbers. */
private readonly saved = new Map<string, string | number>();
/** Keep a value a step saved from a JSON response; a secret one is redacted from everything recorded after it. */
/** Code units the current saved values keep, and those the redaction spellings of secret saves keep for the rest of the scenario. */
private savedUnits = 0;
private secretSpellingUnits = 0;
/** Keep a value a step saved from a JSON response; a secret one is redacted from everything recorded after it. Refused before it is kept when the scenario would hold more than {@link MAX_RETAINED_SAVED_CHARACTERS}. */
save(
label: string,
name: string,
value: string | number,
secret: boolean,
): void {
const text = String(value);
const replaced = this.saved.get(name);
const savedUnits =
this.savedUnits -
(replaced === undefined ? 0 : String(replaced).length) +
text.length;
let kept = savedUnits + this.secretSpellingUnits;
const spellings =
secret && kept <= MAX_RETAINED_SAVED_CHARACTERS ? spellingUnits(text) : 0;
kept += spellings;
if (kept > MAX_RETAINED_SAVED_CHARACTERS)
throw new Error(
`${name} cannot be saved: the scenario would keep more than ${String(MAX_RETAINED_SAVED_CHARACTERS)} characters of saved values.`,
);
this.saved.set(name, value);
if (secret) this.admitCredential(label, String(value));
this.savedUnits = savedUnits;
this.secretSpellingUnits += spellings;
if (secret) this.admitCredential(label, text);
}
/** A saved value with the JSON type it was saved with. */
@@ -1093,6 +1093,47 @@ describe("values a URL path or Basic header cannot carry", () => {
});
});
describe("saved value retention", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("refuses a new saved name that would keep more than the limit, before keeping it", () => {
const secrets = resolved(scenario([requestStep({})]));
secrets.save("a.first", "first", "x".repeat(3_000_000), false);
expect(() =>
secrets.save("b.second", "second", "y".repeat(1_500_000), false),
).toThrow("more than 4000000 characters of saved values");
expect(() => secrets.savedValue("second")).toThrow("No earlier step saved");
// A later save of the same name replaces its value rather than adding to it.
secrets.save("c.first", "first", "z".repeat(3_500_000), false);
expect(String(secrets.savedValue("first"))).toHaveLength(3_500_000);
});
it("counts the redaction spellings a secret save keeps", () => {
const plain = resolved(scenario([requestStep({})]));
plain.save("a.token", "token", "t".repeat(2_500_000), false);
const secret = resolved(scenario([requestStep({})]));
expect(() =>
secret.save("a.token", "token", "t".repeat(2_500_000), true),
).toThrow("more than 4000000 characters of saved values");
});
it("fails the step that would save past the limit", async () => {
stubFetch(201, [`{"id":"${"y".repeat(200_000)}"}`]);
const parsed = scenario([
requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
]);
const secrets = resolved(parsed);
secrets.save("seed.big", "big", "x".repeat(3_900_000), false);
await expect(
perform(fakePage({}), parsed.actions[0]!, secrets),
).rejects.toThrow(
"Step create: item cannot be saved: the scenario would keep more than",
);
});
});
describe("goto placeholders", () => {
it("fills a goto destination's placeholders, percent-encoded", async () => {
const parsed = scenario([
+7 -1
View File
@@ -650,6 +650,12 @@ const saveValues = (
throw new ScenarioRequestError(
`Step ${stepId}: the number at ${json_pointer} is too large to keep exactly, so ${name} cannot be saved from it.`,
);
secrets.save(`${stepId}.${name}`, name, value, secret);
try {
secrets.save(`${stepId}.${name}`, name, value, secret);
} catch (cause) {
throw new ScenarioRequestError(
`Step ${stepId}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
};