fix(security): isolate session cost ledger keys

Use Map-backed cost ledgers so externally derived session and run identifiers never cross ordinary object prototype semantics. Preserve the existing JSON storage format and extend browser and isolated ledger regressions for replay, overflow, legacy data, and reserved keys.
This commit is contained in:
Alexandre Teixeira
2026-10-06 00:25:24 +01:00
parent 56e06de545
commit 39b8563213
3 changed files with 118 additions and 38 deletions
+70 -33
View File
@@ -1331,17 +1331,54 @@ const _COST_RUNS_KEY = 'ody-session-cost-runs';
const _MAX_COST_RUNS_PER_SESSION = 256; const _MAX_COST_RUNS_PER_SESSION = 256;
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger'; const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
/**
* Decode one persisted ledger object into a Map.
*
* Ledger keys are external identifiers. Keeping them out of ordinary object
* property assignment means values such as "__proto__" can never interact
* with Object.prototype. The persisted JSON shape remains an ordinary object
* for backwards compatibility.
*/
function _readCostLedger(storageKey) {
const parsed = JSON.parse(localStorage.getItem(storageKey) || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map();
return new Map(Object.entries(parsed));
}
function _writeCostLedger(storageKey, ledger) {
localStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(ledger)));
}
function _readCostRunLedger() {
const sessions = _readCostLedger(_COST_RUNS_KEY);
for (const [sid, runs] of sessions) {
sessions.set(
sid,
runs && typeof runs === 'object' && !Array.isArray(runs)
? new Map(Object.entries(runs))
: new Map(),
);
}
return sessions;
}
function _writeCostRunLedger(sessions) {
const serialized = new Map();
for (const [sid, runs] of sessions) {
serialized.set(sid, Object.fromEntries(runs));
}
_writeCostLedger(_COST_RUNS_KEY, serialized);
}
/** Return the accumulated cost for the current (or given) session. */ /** Return the accumulated cost for the current (or given) session. */
export function getSessionCost(sessionId) { export function getSessionCost(sessionId) {
const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
if (!sid) return 0; if (!sid) return 0;
try { try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); const costs = _readCostLedger(_COST_KEY);
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); const runCosts = _readCostRunLedger();
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object' const recordedRuns = runCosts.get(sid) || new Map();
? Object.values(runCosts[sid]) return (costs.get(sid) || 0) + Array.from(recordedRuns.values()).reduce(
: [];
return (costs[sid] || 0) + recordedRuns.reduce(
(total, value) => total + (Number(value) || 0), (total, value) => total + (Number(value) || 0),
0, 0,
); );
@@ -1353,12 +1390,12 @@ export function resetSessionCost(sessionId) {
const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId());
if (!sid) return; if (!sid) return;
try { try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); const costs = _readCostLedger(_COST_KEY);
delete costs[sid]; costs.delete(sid);
localStorage.setItem(_COST_KEY, JSON.stringify(costs)); _writeCostLedger(_COST_KEY, costs);
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); const runCosts = _readCostRunLedger();
delete runCosts[sid]; runCosts.delete(sid);
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); _writeCostRunLedger(runCosts);
} catch (_e) { /* ignore */ } } catch (_e) { /* ignore */ }
updateSessionCostUI(); updateSessionCostUI();
} }
@@ -1407,34 +1444,34 @@ export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl
const writeCost = () => { const writeCost = () => {
if (runId) { if (runId) {
try { try {
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); const runCosts = _readCostRunLedger();
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object' const sessionRuns = runCosts.get(sid) || new Map();
? runCosts[sid] // Detached-run identity is replay-idempotent even when a refresh
: {}; // produces a fresh metrics object. Map keys also avoid all
// Assigning by detached-run identity is replay-idempotent even when a // Object.prototype lookup and assignment semantics.
// refresh produces a fresh metrics object. The Web Lock around this sessionRuns.set(runId, cost);
// read/modify/write also keeps distinct runs from two tabs from const entries = Array.from(sessionRuns.entries());
// overwriting one another's stale snapshot.
sessionRuns[runId] = cost;
const entries = Object.entries(sessionRuns);
if (entries.length > _MAX_COST_RUNS_PER_SESSION) { if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION); const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); const costs = _readCostLedger(_COST_KEY);
costs[sid] = (costs[sid] || 0) + overflow.reduce( costs.set(
(total, entry) => total + (Number(entry[1]) || 0), sid,
0, (costs.get(sid) || 0) + overflow.reduce(
(total, entry) => total + (Number(entry[1]) || 0),
0,
),
); );
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]); overflow.forEach(([oldRunId]) => sessionRuns.delete(oldRunId));
localStorage.setItem(_COST_KEY, JSON.stringify(costs)); _writeCostLedger(_COST_KEY, costs);
} }
runCosts[sid] = sessionRuns; runCosts.set(sid, sessionRuns);
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); _writeCostRunLedger(runCosts);
} catch (_e) { /* ignore */ } } catch (_e) { /* ignore */ }
} else { } else {
try { try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); const costs = _readCostLedger(_COST_KEY);
costs[sid] = (costs[sid] || 0) + cost; costs.set(sid, (costs.get(sid) || 0) + cost);
localStorage.setItem(_COST_KEY, JSON.stringify(costs)); _writeCostLedger(_COST_KEY, costs);
} catch (_e) { /* ignore */ } } catch (_e) { /* ignore */ }
} }
metrics._costRecorded = true; metrics._costRecorded = true;
+34 -5
View File
@@ -114,7 +114,7 @@ function documentFunction(name, text = source) {
await page.setContent('<!doctype html><textarea id="doc-editor-textarea"></textarea>'); await page.setContent('<!doctype html><textarea id="doc-editor-textarea"></textarea>');
const ledger = await page.evaluate(async () => { const ledger = await page.evaluate(async () => {
const { recordSessionMetricsCost, getSessionCost } = await import('/static/js/chatRenderer.js'); const { recordSessionMetricsCost, getSessionCost, resetSessionCost } = await import('/static/js/chatRenderer.js');
const metrics = id => ({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10, const metrics = id => ({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10,
endpoint_cost_tracked: true, _costRecordId: id }); endpoint_cost_tracked: true, _costRecordId: id });
const before = Object.getOwnPropertyDescriptors(Object.prototype); const before = Object.getOwnPropertyDescriptors(Object.prototype);
@@ -149,11 +149,37 @@ function documentFunction(name, text = source) {
await navigator.locks.request('odysseus-session-cost-ledger', () => {}); await navigator.locks.request('odysseus-session-cost-ledger', () => {});
for (let i = 0; i < 257; i++) recordSessionMetricsCost(metrics('run-' + i), 'overflow-session'); for (let i = 0; i < 257; i++) recordSessionMetricsCost(metrics('run-' + i), 'overflow-session');
await navigator.locks.request('odysseus-session-cost-ledger', () => {}); await navigator.locks.request('odysseus-session-cost-ledger', () => {});
// Snapshot all ordinary-ledger results before the reserved-key fixture
// deliberately replaces localStorage below.
const replayCost = getSessionCost('safe-session');
const legacyCost = getSessionCost('legacy-session');
const dottedCost = getSessionCost('safe.__proto__.session');
const overflowCost = getSessionCost('overflow-session');
const runWire = JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}');
const costWire = JSON.parse(localStorage.getItem('ody-session-cost') || '{}');
const retainedRuns = Object.keys(runWire['overflow-session']).length;
const wireShape = !Array.isArray(runWire)
&& !Array.isArray(costWire)
&& !!runWire['overflow-session']
&& !Array.isArray(runWire['overflow-session']);
// A persisted legacy/reserved key must remain data rather than becoming
// prototype state. Reading and removing it must also be side-effect free.
localStorage.setItem('ody-session-cost', '{"__proto__":1}');
localStorage.setItem('ody-session-cost-runs', '{"__proto__":{"legacy-run":2}}');
const legacyReservedCost = getSessionCost('__proto__');
resetSessionCost('__proto__');
const clearedReserved = !Object.hasOwn(
JSON.parse(localStorage.getItem('ody-session-cost') || '{}'),
'__proto__',
) && !Object.hasOwn(
JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}'),
'__proto__',
);
const after = Object.getOwnPropertyDescriptors(Object.prototype); const after = Object.getOwnPropertyDescriptors(Object.prototype);
return { cost, replayCost: getSessionCost('safe-session'), legacyCost: getSessionCost('legacy-session'), return { cost, replayCost, legacyCost, dottedCost,
dottedCost: getSessionCost('safe.__proto__.session'), overflowCost, retainedRuns, wireShape, legacyReservedCost, clearedReserved,
overflowCost: getSessionCost('overflow-session'),
retainedRuns: Object.keys(JSON.parse(localStorage.getItem('ody-session-cost-runs'))['overflow-session']).length,
unchanged: Reflect.ownKeys(before).length === Reflect.ownKeys(after).length unchanged: Reflect.ownKeys(before).length === Reflect.ownKeys(after).length
&& Reflect.ownKeys(before).every(key => Reflect.ownKeys(before[key]).every(field => before[key][field] === after[key]?.[field])) }; && Reflect.ownKeys(before).every(key => Reflect.ownKeys(before[key]).every(field => before[key][field] === after[key]?.[field])) };
}); });
@@ -164,6 +190,9 @@ function documentFunction(name, text = source) {
assert.equal(ledger.dottedCost, ledger.cost); assert.equal(ledger.dottedCost, ledger.cost);
assert(Math.abs(ledger.overflowCost - 257 * ledger.cost) < 1e-10); assert(Math.abs(ledger.overflowCost - 257 * ledger.cost) < 1e-10);
assert.equal(ledger.retainedRuns, 256); assert.equal(ledger.retainedRuns, 256);
assert.equal(ledger.wireShape, true);
assert.equal(ledger.legacyReservedCost, 3);
assert.equal(ledger.clearedReserved, true);
const names = ['_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper', const names = ['_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper',
'_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply', '_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply',
@@ -122,6 +122,8 @@ def test_repeated_live_metrics_render_records_session_cost_once():
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }", "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"), _function_source("_billableCost"),
_function_source("_metricsBillableCost"), _function_source("_metricsBillableCost"),
_function_source("_readCostLedger"),
_function_source("_writeCostLedger"),
_function_source("recordSessionMetricsCost"), _function_source("recordSessionMetricsCost"),
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};", "const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
"recordSessionMetricsCost(metrics);", "recordSessionMetricsCost(metrics);",
@@ -150,6 +152,10 @@ def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }", "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"), _function_source("_billableCost"),
_function_source("_metricsBillableCost"), _function_source("_metricsBillableCost"),
_function_source("_readCostLedger"),
_function_source("_writeCostLedger"),
_function_source("_readCostRunLedger"),
_function_source("_writeCostRunLedger"),
_function_source("recordSessionMetricsCost"), _function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"), _function_source("getSessionCost"),
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};", "const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
@@ -180,6 +186,10 @@ def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }", "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"), _function_source("_billableCost"),
_function_source("_metricsBillableCost"), _function_source("_metricsBillableCost"),
_function_source("_readCostLedger"),
_function_source("_writeCostLedger"),
_function_source("_readCostRunLedger"),
_function_source("_writeCostRunLedger"),
_function_source("recordSessionMetricsCost"), _function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"), _function_source("getSessionCost"),
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});", "recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
@@ -207,6 +217,10 @@ def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
"const badge = {style: {}, textContent: ''};", "const badge = {style: {}, textContent: ''};",
"const document = {getElementById() { return badge; }};", "const document = {getElementById() { return badge; }};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};", "const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
_function_source("_readCostLedger"),
_function_source("_writeCostLedger"),
_function_source("_readCostRunLedger"),
_function_source("_writeCostRunLedger"),
_function_source("getSessionCost"), _function_source("getSessionCost"),
_function_source("updateSessionCostUI"), _function_source("updateSessionCostUI"),
"updateSessionCostUI();", "updateSessionCostUI();",