mirror of https://github.com/garrytan/gstack.git
fix(browse): NTFS ACL hardening for Windows state files via icacls
gstack's ~/.gstack/ state directory holds bearer tokens, canary tokens, agent
queue contents (with prompt history), session state, security-decision logs,
and saved cookie bundles — all written with { mode: 0o600 } / 0o700. On Windows,
those mode bits are a silent no-op: Node's fs module doesn't translate POSIX
modes to NTFS ACLs, and inherited ACLs leave every "restricted" file readable
by other principals on the machine (verified via icacls — six ACEs, the
intended user is the LAST of six).
Threat model is non-trivial on:
- Self-hosted CI runners (different service account on the same Windows box
can read developer tokens, canary tokens, prompt history)
- Shared development machines (agencies, studios, lab environments)
- Multi-tenant servers with shared home directories
Orthogonal to v1.24.0.0's binary-resolution work — complementary at the write
side. v1.24's bin/gstack-paths resolves ~/.gstack/ correctly across plugin /
global / local installs; this PR ensures files written into those resolved
paths actually get the POSIX 0o600 semantic translated to NTFS.
The fix:
- New browse/src/file-permissions.ts (158 LOC, 5 public + 1 test-reset).
restrictFilePermissions / restrictDirectoryPermissions wrap chmod (POSIX)
or icacls /inheritance:r /grant:r <user>:(F) (Windows). writeSecureFile /
appendSecureFile / mkdirSecure are drop-in wrappers for the common patterns.
- 19 call sites converted across 9 source files: browser-manager.ts,
browser-skill-write.ts, cli.ts, config.ts, meta-commands.ts,
security-classifier.ts, security.ts (4 sites), server.ts (5 sites),
terminal-agent.ts (8 sites), tunnel-denial-log.ts.
- (OI)(CI) inheritance flags on directories mean files created via fs.write*
*inside* an mkdirSecure-created dir inherit the owner-only ACL automatically
— important for tunnel-denial-log.ts where appends use async fsp.appendFile.
Error handling: icacls failures (nonexistent path, missing icacls.exe, hardened
environments) log a one-shot warning to stderr and proceed. Once-per-process
gating prevents log spam if the condition persists. Filesystem stays
functional; the file just ends up with inherited ACLs.
Test plan:
- bun test browse/test/file-permissions.test.ts — 13 pass, 0 fail (POSIX
mode-bit assertions, Windows no-throw, mkdir idempotence, recursive
creation, Buffer payloads, append-creates-then-reapplies-once semantics)
- bun test browse/test/security.test.ts — 38 pass, 0 fail (existing security
test suite plus the bash-binary resolution tests added in fix #1119; the
converted writeFileSync/appendFileSync/mkdirSync sites in security.ts
integrate cleanly)
- Empirical icacls before/after on a real file — 6 ACEs → 1 ACE
- bun build typecheck on all modified files — clean (server.ts has a
pre-existing playwright-core/electron resolution issue unrelated to this PR)
POSIX behavior is bit-identical to old code — fs.chmodSync(path, 0o6XX) on the
helper's POSIX branch matches the inline { mode: 0o6XX } it replaces. Linux
and macOS see no behavior change.
Inviting pushback on three judgment calls (in PR description):
1. icacls vs npm library
2. ACL scope — just user, or user + SYSTEM?
3. Graceful degradation — once-per-process warn, not silent, not hard-fail.
This commit is contained in:
parent
bf65487162
commit
dd8402c8b4
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
|
||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { TabSession, type RefEntry } from './tab-session';
|
||||
|
|
@ -267,10 +268,10 @@ export class BrowserManager {
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||||
fs.mkdirSync(gstackDir, { recursive: true });
|
||||
mkdirSecure(gstackDir);
|
||||
const authFile = path.join(gstackDir, '.auth.json');
|
||||
try {
|
||||
fs.writeFileSync(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }), { mode: 0o600 });
|
||||
writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }));
|
||||
} catch (err: any) {
|
||||
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { mkdirSecure } from './file-permissions';
|
||||
import { isPathWithin } from './platform';
|
||||
import type { TierPaths } from './browser-skills';
|
||||
import { defaultTierPaths } from './browser-skills';
|
||||
|
|
@ -74,8 +75,8 @@ export function stageSkill(opts: StageSkillOptions): string {
|
|||
const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
|
||||
const stagedDir = path.join(wrapperDir, opts.name);
|
||||
|
||||
fs.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(stagedDir, { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(wrapperDir);
|
||||
mkdirSecure(stagedDir);
|
||||
|
||||
for (const [relPath, contents] of opts.files) {
|
||||
if (relPath.startsWith('/') || relPath.includes('..')) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
|
||||
const config = resolveConfig();
|
||||
|
|
@ -729,7 +730,7 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
|||
scopes: pairData.scopes,
|
||||
expires_at: pairData.expires_at,
|
||||
};
|
||||
fs.writeFileSync(configFile, JSON.stringify(configData, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(configFile, JSON.stringify(configData, null, 2));
|
||||
console.log(`Connected. ${localHost} can now use the browser.`);
|
||||
console.log(`Config written to: ${configFile}`);
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { mkdirSecure } from './file-permissions';
|
||||
|
||||
export interface BrowseConfig {
|
||||
projectDir: string;
|
||||
|
|
@ -81,7 +82,7 @@ export function resolveConfig(
|
|||
*/
|
||||
export function ensureStateDir(config: BrowseConfig): void {
|
||||
try {
|
||||
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(config.stateDir);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EACCES') {
|
||||
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Cross-platform file permission restriction for sensitive gstack state.
|
||||
*
|
||||
* Why this exists
|
||||
* ----------------
|
||||
* POSIX mode bits (`0o600` for files, `0o700` for dirs) are how gstack marks
|
||||
* sensitive state files — auth tokens, canary tokens, chat history, agent
|
||||
* queue, device salt, per-tab security decisions. On Linux and macOS,
|
||||
* `fs.chmodSync(path, 0o600)` and `fs.writeFileSync(path, data, { mode: 0o600 })`
|
||||
* do exactly what you'd hope: the file ends up readable and writable only
|
||||
* by the owning user, no access for group / other.
|
||||
*
|
||||
* On Windows, both calls are effectively no-ops. NTFS uses ACLs, not POSIX
|
||||
* mode bits, and Node's fs module doesn't translate. So on every Windows
|
||||
* install, sensitive gstack state files inherit whatever ACL the parent
|
||||
* directory grants — typically user-full + inherited admin-full. That's
|
||||
* fine on a single-user laptop but leaks on:
|
||||
*
|
||||
* - Self-hosted CI runners (GitHub Actions / GitLab / Jenkins agents
|
||||
* running as a different service account on the same box — they can
|
||||
* read developer state)
|
||||
* - Shared development machines (agencies, studios, lab machines)
|
||||
* - Multi-tenant servers with shared home directories
|
||||
* - Malware running as the same user (no in-user-account isolation)
|
||||
*
|
||||
* This module wraps the platform-correct call. POSIX: chmod. Windows:
|
||||
* icacls with inheritance break + explicit user grant. Failures on either
|
||||
* platform are best-effort — the filesystem is still functional if ACL
|
||||
* restriction fails; we just don't hit the intended hardening target.
|
||||
*
|
||||
* Warning behavior: to avoid spamming the console on a machine where
|
||||
* icacls is unavailable (rare — it ships in System32 on every Windows
|
||||
* version since 7), we log the first failure per process and stay silent
|
||||
* afterward. The warning includes the advice "sensitive files may be
|
||||
* readable by other accounts on this machine" so operators know to audit
|
||||
* their runner / share setup.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
||||
let warnedOnce = false;
|
||||
|
||||
function warnIcaclsFailure(fsPath: string, err: unknown): void {
|
||||
if (warnedOnce) return;
|
||||
warnedOnce = true;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// biome-ignore lint/suspicious/noConsole: intentional user-facing warning
|
||||
console.warn(
|
||||
`[gstack] Failed to restrict Windows ACL on ${fsPath}: ${msg}\n` +
|
||||
` Sensitive files may be readable by other accounts on this machine.\n` +
|
||||
` This warning appears once per process; subsequent failures are silent.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a file to owner-only access (POSIX 0o600 equivalent).
|
||||
*
|
||||
* POSIX: `fs.chmodSync(path, 0o600)`. Idempotent if the file was already
|
||||
* written with `{ mode: 0o600 }`, so safe to call regardless.
|
||||
*
|
||||
* Windows: invokes `icacls /inheritance:r /grant:r <user>:(F)` to remove
|
||||
* any inherited ACLs and replace the ACL with a single entry granting the
|
||||
* current user full control.
|
||||
*/
|
||||
export function restrictFilePermissions(filePath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const user = os.userInfo().username;
|
||||
execFileSync(
|
||||
'icacls',
|
||||
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(filePath, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { fs.chmodSync(filePath, 0o600); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a directory to owner-only access (POSIX 0o700 equivalent),
|
||||
* with new children inheriting the restricted ACL.
|
||||
*
|
||||
* POSIX: `fs.chmodSync(path, 0o700)`. Idempotent if the dir was already
|
||||
* created with `{ mode: 0o700 }`.
|
||||
*
|
||||
* Windows: `icacls /inheritance:r /grant:r <user>:(OI)(CI)(F)`. The
|
||||
* `(OI)(CI)` flags make new files (OI = object inherit) and subdirs
|
||||
* (CI = container inherit) inherit the single-user-full ACL — important
|
||||
* because child creations in `fs.writeFileSync(...)` without explicit
|
||||
* `restrictFilePermissions` still end up owner-only.
|
||||
*/
|
||||
export function restrictDirectoryPermissions(dirPath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const user = os.userInfo().username;
|
||||
execFileSync(
|
||||
'icacls',
|
||||
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} catch (err) {
|
||||
warnIcaclsFailure(dirPath, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try { fs.chmodSync(dirPath, 0o700); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file and restrict it to owner-only access, cross-platform.
|
||||
* Replaces `fs.writeFileSync(path, data, { mode: 0o600 })` + Windows ACL.
|
||||
*/
|
||||
export function writeSecureFile(
|
||||
filePath: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
): void {
|
||||
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
||||
restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append to a file with owner-only permissions, cross-platform.
|
||||
* Replaces `fs.appendFileSync(path, data, { mode: 0o600 })` + Windows ACL.
|
||||
*
|
||||
* ACL is applied only on first write — subsequent appends are fire-and-forget
|
||||
* (no need to re-run icacls on every log line).
|
||||
*/
|
||||
export function appendSecureFile(
|
||||
filePath: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
): void {
|
||||
const existed = fs.existsSync(filePath);
|
||||
fs.appendFileSync(filePath, data, { mode: 0o600 });
|
||||
if (!existed) restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* `mkdir -p` with owner-only directory permissions, cross-platform.
|
||||
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
|
||||
* Safe to call on an existing directory — re-applies the ACL idempotently.
|
||||
*/
|
||||
export function mkdirSecure(dirPath: string): void {
|
||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||
restrictDirectoryPermissions(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the once-per-process warning gate. Test-only.
|
||||
*/
|
||||
export function __resetWarnedForTests(): void {
|
||||
warnedOnce = false;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export { validateOutputPath, escapeRegExp } from './path-security';
|
|||
import * as Diff from 'diff';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { resolveConfig } from './config';
|
||||
import type { Frame } from 'playwright';
|
||||
|
|
@ -917,7 +918,7 @@ export async function handleMetaCommand(
|
|||
|
||||
const config = resolveConfig();
|
||||
const stateDir = path.join(config.stateDir, 'browse-states');
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
mkdirSecure(stateDir);
|
||||
const statePath = path.join(stateDir, `${name}.json`);
|
||||
|
||||
if (action === 'save') {
|
||||
|
|
@ -929,7 +930,7 @@ export async function handleMetaCommand(
|
|||
cookies: state.cookies,
|
||||
pages: state.pages.map(p => ({ url: p.url, isActive: p.isActive })),
|
||||
};
|
||||
fs.writeFileSync(statePath, JSON.stringify(saveData, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(statePath, JSON.stringify(saveData, null, 2));
|
||||
return `State saved: ${statePath} (${state.cookies.length} cookies, ${state.pages.length} pages)\n⚠️ Cookies stored in plaintext. Delete when no longer needed.`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { spawn } from 'child_process';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { mkdirSecure } from './file-permissions';
|
||||
import { THRESHOLDS, type LayerSignal } from './security';
|
||||
import { resolveClaudeCommand } from './claude-bin';
|
||||
|
||||
|
|
@ -156,7 +157,7 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
|||
}
|
||||
|
||||
async function ensureTestsavantStaged(onProgress?: (msg: string) => void): Promise<void> {
|
||||
fs.mkdirSync(path.join(TESTSAVANT_DIR, 'onnx'), { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(path.join(TESTSAVANT_DIR, 'onnx'));
|
||||
|
||||
// Small config/tokenizer files
|
||||
for (const f of TESTSAVANT_FILES) {
|
||||
|
|
@ -301,7 +302,7 @@ export async function scanPageContent(text: string): Promise<LayerSignal> {
|
|||
// ─── L4c: DeBERTa-v3 ensemble (opt-in) ───────────────────────
|
||||
|
||||
async function ensureDebertaStaged(onProgress?: (msg: string) => void): Promise<void> {
|
||||
fs.mkdirSync(path.join(DEBERTA_DIR, 'onnx'), { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(path.join(DEBERTA_DIR, 'onnx'));
|
||||
for (const f of DEBERTA_FILES) {
|
||||
const dst = path.join(DEBERTA_DIR, f);
|
||||
if (fs.existsSync(dst)) continue;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { spawn } from 'child_process';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { writeSecureFile, appendSecureFile, mkdirSecure } from './file-permissions';
|
||||
|
||||
// ─── Thresholds + verdict types ──────────────────────────────
|
||||
|
||||
|
|
@ -344,11 +345,11 @@ function getDeviceSalt(): string {
|
|||
// fall through to generate
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(SECURITY_DIR);
|
||||
} catch {}
|
||||
cachedSalt = randomBytes(16).toString('hex');
|
||||
try {
|
||||
fs.writeFileSync(SALT_FILE, cachedSalt, { mode: 0o600 });
|
||||
writeSecureFile(SALT_FILE, cachedSalt);
|
||||
} catch {
|
||||
// Can't persist (read-only fs, disk full). Keep the in-memory salt
|
||||
// for this process so cross-log correlation still works within a
|
||||
|
|
@ -456,10 +457,10 @@ export function logAttempt(record: AttemptRecord): boolean {
|
|||
// the event reported (it goes to a different directory anyway).
|
||||
reportAttemptTelemetry(record);
|
||||
try {
|
||||
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(SECURITY_DIR);
|
||||
rotateIfNeeded();
|
||||
const line = JSON.stringify(record) + '\n';
|
||||
fs.appendFileSync(ATTEMPTS_LOG, line, { mode: 0o600 });
|
||||
appendSecureFile(ATTEMPTS_LOG, line);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Non-fatal. Log to stderr for debugging but don't block.
|
||||
|
|
@ -489,9 +490,9 @@ export interface SessionState {
|
|||
*/
|
||||
export function writeSessionState(state: SessionState): void {
|
||||
try {
|
||||
fs.mkdirSync(SECURITY_DIR, { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(SECURITY_DIR);
|
||||
const tmp = `${STATE_FILE}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmp, JSON.stringify(state, null, 2));
|
||||
fs.renameSync(tmp, STATE_FILE);
|
||||
} catch (err) {
|
||||
console.error('[security] writeSessionState failed:', (err as Error).message);
|
||||
|
|
@ -532,10 +533,10 @@ export interface DecisionRecord {
|
|||
|
||||
export function writeDecision(record: DecisionRecord): void {
|
||||
try {
|
||||
fs.mkdirSync(DECISIONS_DIR, { recursive: true, mode: 0o700 });
|
||||
mkdirSecure(DECISIONS_DIR);
|
||||
const file = decisionFileForTab(record.tabId);
|
||||
const tmp = `${file}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
||||
writeSecureFile(tmp, JSON.stringify(record));
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (err) {
|
||||
console.error('[security] writeDecision failed:', (err as Error).message);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
|
||||
} from './content-security';
|
||||
import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||
import {
|
||||
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
|
||||
|
|
@ -1477,7 +1478,7 @@ async function start() {
|
|||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2));
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
|
||||
return new Response(JSON.stringify({ url: tunnelUrl }), {
|
||||
|
|
@ -2000,7 +2001,7 @@ async function start() {
|
|||
mode: browserManager.getConnectionMode(),
|
||||
};
|
||||
const tmpFile = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmpFile, JSON.stringify(state, null, 2));
|
||||
fs.renameSync(tmpFile, config.stateFile);
|
||||
|
||||
browserManager.serverPort = port;
|
||||
|
|
@ -2081,7 +2082,7 @@ async function start() {
|
|||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2));
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] Failed to start tunnel: ${err.message}`);
|
||||
|
|
@ -2111,7 +2112,7 @@ async function start() {
|
|||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnelLocalPort = tunnelPort;
|
||||
const tmpState = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmpState, JSON.stringify(stateContent, null, 2));
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`);
|
||||
|
|
@ -2125,8 +2126,8 @@ start().catch((err) => {
|
|||
// stderr because the server is launched with detached: true, stdio: 'ignore'.
|
||||
try {
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`, { mode: 0o600 });
|
||||
mkdirSecure(config.stateDir);
|
||||
writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
|
||||
} catch {
|
||||
// stateDir may not exist — nothing more we can do
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { safeUnlink } from './error-handling';
|
||||
|
||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
|
||||
|
|
@ -83,7 +84,7 @@ function findClaude(): string | null {
|
|||
/** Probe + persist claude availability for the bootstrap card. */
|
||||
function writeClaudeAvailable(): void {
|
||||
const stateDir = path.dirname(STATE_FILE);
|
||||
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
|
||||
try { mkdirSecure(stateDir); } catch {}
|
||||
const found = findClaude();
|
||||
const status = {
|
||||
available: !!found,
|
||||
|
|
@ -94,7 +95,7 @@ function writeClaudeAvailable(): void {
|
|||
const target = path.join(stateDir, 'claude-available.json');
|
||||
const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmp, JSON.stringify(status, null, 2));
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
safeUnlink(tmp);
|
||||
|
|
@ -422,7 +423,7 @@ function handleTabState(msg: {
|
|||
reason?: string;
|
||||
}): void {
|
||||
const stateDir = path.dirname(STATE_FILE);
|
||||
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
|
||||
try { mkdirSecure(stateDir); } catch {}
|
||||
|
||||
// tabs.json — full list
|
||||
if (Array.isArray(msg.tabs)) {
|
||||
|
|
@ -442,7 +443,7 @@ function handleTabState(msg: {
|
|||
const target = path.join(stateDir, 'tabs.json');
|
||||
const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
||||
writeSecureFile(tmp, JSON.stringify(payload, null, 2));
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
safeUnlink(tmp);
|
||||
|
|
@ -457,11 +458,11 @@ function handleTabState(msg: {
|
|||
const ctxFile = path.join(stateDir, 'active-tab.json');
|
||||
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify({
|
||||
writeSecureFile(tmp, JSON.stringify({
|
||||
tabId: active.tabId ?? null,
|
||||
url: active.url,
|
||||
title: active.title ?? '',
|
||||
}), { mode: 0o600 });
|
||||
}));
|
||||
fs.renameSync(tmp, ctxFile);
|
||||
} catch {
|
||||
safeUnlink(tmp);
|
||||
|
|
@ -477,11 +478,11 @@ function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }):
|
|||
const ctxFile = path.join(stateDir, 'active-tab.json');
|
||||
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify({
|
||||
writeSecureFile(tmp, JSON.stringify({
|
||||
tabId: msg.tabId ?? null,
|
||||
url,
|
||||
title: msg.title ?? '',
|
||||
}), { mode: 0o600 });
|
||||
}));
|
||||
fs.renameSync(tmp, ctxFile);
|
||||
} catch {
|
||||
safeUnlink(tmp);
|
||||
|
|
@ -524,9 +525,9 @@ function main() {
|
|||
|
||||
// Write port file atomically so the parent server can pick it up.
|
||||
const dir = path.dirname(PORT_FILE);
|
||||
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {}
|
||||
try { mkdirSecure(dir); } catch {}
|
||||
const tmp = `${PORT_FILE}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, String(port), { mode: 0o600 });
|
||||
writeSecureFile(tmp, String(port));
|
||||
fs.renameSync(tmp, PORT_FILE);
|
||||
|
||||
// Hand the parent the internal token so it can call /internal/grant.
|
||||
|
|
@ -549,8 +550,8 @@ function main() {
|
|||
// to a state file the parent reads. This avoids env-passing races. See main().
|
||||
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(INTERNAL_TOKEN_FILE), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN, { mode: 0o600 });
|
||||
mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE));
|
||||
writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN);
|
||||
} catch {}
|
||||
|
||||
main();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import { promises as fsp } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { mkdirSecure } from './file-permissions';
|
||||
|
||||
const LOG_DIR = path.join(os.homedir(), '.gstack', 'security');
|
||||
const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl');
|
||||
|
|
@ -31,7 +32,10 @@ let dirEnsured = false;
|
|||
async function ensureDir(): Promise<void> {
|
||||
if (dirEnsured) return;
|
||||
try {
|
||||
await fsp.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
|
||||
// Sync mkdir is fine here — runs once per process at first denial. The
|
||||
// (OI)(CI) inheritance set on Windows means subsequent fsp.appendFile
|
||||
// writes pick up the owner-only ACL automatically.
|
||||
mkdirSecure(LOG_DIR);
|
||||
dirEnsured = true;
|
||||
} catch {
|
||||
// Swallow — log writes are best-effort. Failure to mkdir just means
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Unit tests for browse/src/file-permissions.ts
|
||||
*
|
||||
* Strategy:
|
||||
* - POSIX assertions check fs.statSync.mode bits directly (cheap, reliable,
|
||||
* runs on every CI config).
|
||||
* - Windows assertions don't check ACLs (that'd require parsing icacls
|
||||
* output, which is brittle across Windows versions / locales). Instead
|
||||
* we verify the helper doesn't throw and the file ends up accessible
|
||||
* to the current user — the "doesn't crash, file still usable"
|
||||
* contract the callers rely on.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
restrictFilePermissions,
|
||||
restrictDirectoryPermissions,
|
||||
writeSecureFile,
|
||||
appendSecureFile,
|
||||
mkdirSecure,
|
||||
__resetWarnedForTests,
|
||||
} from '../src/file-permissions';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-perms-'));
|
||||
__resetWarnedForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
describe('restrictFilePermissions', () => {
|
||||
test('on POSIX, sets file mode to 0o600', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const p = path.join(tmpDir, 'secret');
|
||||
fs.writeFileSync(p, 'token');
|
||||
fs.chmodSync(p, 0o644); // start world-readable to prove the call mutates it
|
||||
restrictFilePermissions(p);
|
||||
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('on Windows, does not throw on an existing file', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const p = path.join(tmpDir, 'secret');
|
||||
fs.writeFileSync(p, 'token');
|
||||
expect(() => restrictFilePermissions(p)).not.toThrow();
|
||||
// File remains readable by the caller — core contract.
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('token');
|
||||
});
|
||||
|
||||
test('on Windows, does not throw when icacls fails (bad path)', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// icacls emits an error for a nonexistent path; helper must swallow.
|
||||
expect(() => restrictFilePermissions(path.join(tmpDir, 'nonexistent'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrictDirectoryPermissions', () => {
|
||||
test('on POSIX, sets directory mode to 0o700', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const d = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(d, { mode: 0o755 });
|
||||
restrictDirectoryPermissions(d);
|
||||
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('on Windows, does not throw on an existing directory', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const d = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(d);
|
||||
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSecureFile', () => {
|
||||
test('writes the payload and restricts permissions atomically', () => {
|
||||
const p = path.join(tmpDir, 'data');
|
||||
writeSecureFile(p, 'hello');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('hello');
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts Buffer payloads', () => {
|
||||
const p = path.join(tmpDir, 'buffer');
|
||||
writeSecureFile(p, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
const out = fs.readFileSync(p);
|
||||
expect(out.length).toBe(4);
|
||||
expect(out[0]).toBe(0xde);
|
||||
});
|
||||
|
||||
test('overwrites existing file', () => {
|
||||
const p = path.join(tmpDir, 'existing');
|
||||
fs.writeFileSync(p, 'old', { mode: 0o644 });
|
||||
writeSecureFile(p, 'new');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSecureFile', () => {
|
||||
test('appends to a new file and sets owner-only permissions', () => {
|
||||
const p = path.join(tmpDir, 'log');
|
||||
appendSecureFile(p, 'line1\n');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('line1\n');
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test('appends without re-applying ACL on subsequent writes', () => {
|
||||
const p = path.join(tmpDir, 'log');
|
||||
appendSecureFile(p, 'line1\n');
|
||||
appendSecureFile(p, 'line2\n');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('line1\nline2\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mkdirSecure', () => {
|
||||
test('creates directory with owner-only mode (POSIX)', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const d = path.join(tmpDir, 'nested', 'deep');
|
||||
mkdirSecure(d);
|
||||
expect(fs.statSync(d).isDirectory()).toBe(true);
|
||||
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('is idempotent — safe to call on existing directory', () => {
|
||||
const d = path.join(tmpDir, 'dir');
|
||||
mkdirSecure(d);
|
||||
expect(() => mkdirSecure(d)).not.toThrow();
|
||||
});
|
||||
|
||||
test('recursive behavior: creates intermediate directories', () => {
|
||||
const d = path.join(tmpDir, 'a', 'b', 'c');
|
||||
mkdirSecure(d);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'a'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
|
||||
expect(fs.existsSync(d)).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue