fix(browse): session snapshots are atomic and the cookie filter drops loopback IP literals

A crash mid-write destroyed the previous good snapshot — the exact
scenario persistence exists to survive; writes now go tmp+rename. The
internal-network cookie filter gains 127.*/::1/169.254.* (a tampered
state file could previously hand loopback-service cookies back to the
browser), and 'state load' imports the shared filter instead of
maintaining a comment-synced copy. Test cleanup made exception-safe.
This commit is contained in:
Garry Tan
2026-08-14 17:12:26 -07:00
parent 24cee40b36
commit 079988ba70
4 changed files with 194 additions and 68 deletions
+8 -9
View File
@@ -20,6 +20,7 @@ import * as path from 'path';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { TEMP_DIR } from './platform';
import { resolveConfig } from './config';
import { filterSessionCookies } from './session-persist';
import type { Frame } from 'playwright';
/** Tokenize a pipe segment respecting double-quoted strings. */
@@ -959,15 +960,13 @@ export async function handleMetaCommand(
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
throw new Error('Invalid state file: expected cookies and pages arrays');
}
// Validate and filter cookies — reject malformed or internal-network cookies
const validatedCookies = data.cookies.filter((c: any) => {
if (typeof c !== 'object' || !c) return false;
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
if (typeof c.domain !== 'string' || !c.domain) return false;
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
return true;
});
// Validate and filter cookies via the shared hygiene filter in
// session-persist.ts (isInternalCookieDomain): rejects malformed
// cookies and internal-network domains — localhost, *.internal,
// loopback literals (127.x, ::1), and link-local/cloud-metadata
// (169.254.x) — that a tampered state file could use to reach local
// services or the metadata endpoint.
const validatedCookies = filterSessionCookies(data.cookies);
if (validatedCookies.length < data.cookies.length) {
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
}
+43 -15
View File
@@ -66,18 +66,34 @@ export function serializeSessionState(state: BrowserState): string {
}
/**
* Same cookie hygiene as `state load` (meta-commands.ts, kept in sync by
* comment there): drop malformed cookies and internal-network domains a
* tampered file could use to reach localhost services or cloud metadata.
* True when a cookie domain points at an internal-network target a tampered
* state file could use to reach localhost services, *.internal hosts, or
* cloud metadata: `localhost`, `*.internal`, IPv4 loopback literals
* (127.0.0.0/8), IPv6 loopback (`::1`, `[::1]`), and link-local/metadata
* (169.254.0.0/16, which covers 169.254.169.254). Leading-dot domain
* variants (`.127.0.0.1`) are normalized before matching. Single source of
* truth for the persistence restore path here AND `state load`
* (meta-commands.ts).
*/
export function isInternalCookieDomain(domain: string): boolean {
const d = domain.startsWith('.') ? domain.slice(1) : domain;
if (d === 'localhost' || d.endsWith('.internal')) return true;
if (d === '::1' || d === '[::1]') return true; // IPv6 loopback
if (/^127\./.test(d)) return true; // IPv4 loopback block
if (/^169\.254\./.test(d)) return true; // link-local incl. cloud metadata
return false;
}
/**
* Cookie hygiene shared with `state load` (meta-commands.ts): drop malformed
* cookies and internal-network domains (see isInternalCookieDomain).
*/
export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] {
return cookies.filter((c: any) => {
if (typeof c !== 'object' || !c) return false;
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
if (typeof c.domain !== 'string' || !c.domain) return false;
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
return true;
return !isInternalCookieDomain(c.domain);
}) as BrowserState['cookies'];
}
@@ -118,21 +134,33 @@ export function deserializeSessionState(raw: string): BrowserState | null {
export async function persistSessionState(bm: BrowserManager, filePath: string): Promise<void> {
if (bm.getConnectionMode() !== 'launched') return;
const state = await bm.saveState();
writeSecureFile(filePath, serializeSessionState(state));
// Atomic replace: stage the new snapshot beside the target, then rename
// over it. A crash mid-write must never destroy the previous good
// snapshot — surviving crashes is the point of this feature.
const tmpPath = `${filePath}.tmp`;
writeSecureFile(tmpPath, serializeSessionState(state));
try {
fs.renameSync(tmpPath, filePath);
} catch (err) {
safeUnlinkQuiet(tmpPath);
throw err;
}
}
/**
* Restore a persisted session into a freshly launched manager. Returns true
* when state was restored, false when there was nothing (or corrupt data —
* which is warned, deleted, and skipped rather than blocking launch).
* restoreState re-validates every URL before navigating.
* Restore a persisted session into a freshly launched manager. Returns the
* restored (already-filtered) state so callers can log counts without an
* extra saveState() round-trip, or null when there was nothing to restore
* (missing file, or corrupt data — which is warned, quarantined, and skipped
* rather than blocking launch). restoreState re-validates every URL before
* navigating.
*/
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<boolean> {
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<BrowserState | null> {
let raw: string;
try {
raw = fs.readFileSync(filePath, 'utf-8');
} catch (err: any) {
if (err?.code === 'ENOENT') return false;
if (err?.code === 'ENOENT') return null;
throw err;
}
const state = deserializeSessionState(raw);
@@ -141,10 +169,10 @@ export async function restoreSessionState(bm: BrowserManager, filePath: string):
// 3-week-later bug report is reconstructable from the artifact.
console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`);
quarantineCorrupt(filePath);
return false;
return null;
}
// launch() opens one blank tab; replace it rather than restoring alongside.
await bm.closeAllPages();
await bm.restoreState(state);
return true;
return state;
}