mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-16 09:12:13 +08:00
* fix: DNS rebinding protection checks AAAA (IPv6) records too Cherry-pick PR #744 by @Gonzih. Closes the IPv6-only DNS rebinding gap by checking both A and AAAA records independently. Co-Authored-By: Gonzih <gonzih@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: validateOutputPath symlink bypass — resolve real path before safe-dir check Cherry-pick PR #745 by @Gonzih. Adds a second pass using fs.realpathSync() to resolve symlinks after lexical path validation. Co-Authored-By: Gonzih <gonzih@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: validate saved URLs before navigation in restoreState Cherry-pick PR #751 by @Gonzih. Prevents navigation to cloud metadata endpoints or file:// URIs embedded in user-writable state files. Co-Authored-By: Gonzih <gonzih@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: telemetry-ingest uses anon key instead of service role key Cherry-pick PR #750 by @Gonzih. The service role key bypasses RLS and grants unrestricted database access — anon key + RLS is the right model for a public telemetry endpoint. Co-Authored-By: Gonzih <gonzih@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: killAgent() actually kills the sidebar claude subprocess Cherry-pick PR #743 by @Gonzih. Implements cross-process kill signaling via kill-file + polling pattern, tracks active processes per-tab. Co-Authored-By: Gonzih <gonzih@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(design): bind server to localhost and validate reload paths Cherry-pick PR #803 by @garagon. Adds hostname: '127.0.0.1' to Bun.serve() and validates /api/reload paths are within cwd() or tmpdir(). Closes C1+C2 from security audit #783. Co-Authored-By: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add auth gate to /inspector/events SSE endpoint (C3) The /inspector/events endpoint had no authentication, unlike /activity/stream which validates tokens. Now requires the same Bearer header or ?token= query param check. Closes C3 from security audit #783. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sanitize design feedback with trust boundary markers (C4+H5) Wrap user feedback in <user-feedback> XML markers with tag escaping to prevent prompt injection via malicious feedback text. Cap accumulated feedback to last 5 iterations to limit incremental poisoning. Closes C4 and H5 from security audit #783. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden file/directory permissions to owner-only (C5+H9+M9+M10) Add mode 0o700 to all mkdirSync calls for state/session directories. Add mode 0o600 to all writeFileSync calls for session.json, chat.jsonl, and log files. Add umask 077 to setup script. Prevents auth tokens, chat history, and browser logs from being world-readable on multi-user systems. Closes C5, H9, M9, M10 from security audit #783. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: TOCTOU race in setup symlink creation (C6) Remove the existence check before mkdir -p (it's idempotent) and validate the target isn't already a symlink before creating the link. Prevents a local attacker from racing between the check and mkdir to redirect SKILL.md writes. Closes C6 from security audit #783. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove CORS wildcard, restrict to localhost (H1) Replace Access-Control-Allow-Origin: * with http://127.0.0.1 on sidebar tab/chat endpoints. The Chrome extension uses manifest host_permissions to bypass CORS entirely, so this only blocks malicious websites from making cross-origin requests. Closes H1 from security audit #783. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: make cookie picker auth mandatory (H2) Remove the conditional if(authToken) guard that skipped auth when authToken was undefined. Now all cookie picker data/action routes reject unauthenticated requests. Closes H2 from security audit #783. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: gate /health token on chrome-extension Origin header Only return the auth token in /health response when the request Origin starts with chrome-extension://. The Chrome extension always sends this origin via manifest host_permissions. Regular HTTP requests (including tunneled ones from ngrok/SSH) won't get the token. The extension also has a fallback path through background.js that reads the token from the state file directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: update server-auth test for chrome-extension Origin gating The test previously checked for 'localhost-only' comment. Now checks for 'chrome-extension://' since the token is gated on Origin header. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.7.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gonzih <gonzih@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: garagon <garagon@users.noreply.github.com>
151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
/**
|
|
* Shared config for browse CLI + server.
|
|
*
|
|
* Resolution:
|
|
* 1. BROWSE_STATE_FILE env → derive stateDir from parent
|
|
* 2. git rev-parse --show-toplevel → projectDir/.gstack/
|
|
* 3. process.cwd() fallback (non-git environments)
|
|
*
|
|
* The CLI computes the config and passes BROWSE_STATE_FILE to the
|
|
* spawned server. The server derives all paths from that env var.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
export interface BrowseConfig {
|
|
projectDir: string;
|
|
stateDir: string;
|
|
stateFile: string;
|
|
consoleLog: string;
|
|
networkLog: string;
|
|
dialogLog: string;
|
|
}
|
|
|
|
/**
|
|
* Detect the git repository root, or null if not in a repo / git unavailable.
|
|
*/
|
|
export function getGitRoot(): string | null {
|
|
try {
|
|
const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
timeout: 2_000, // Don't hang if .git is broken
|
|
});
|
|
if (proc.exitCode !== 0) return null;
|
|
return proc.stdout.toString().trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve all browse config paths.
|
|
*
|
|
* If BROWSE_STATE_FILE is set (e.g. by CLI when spawning server, or by
|
|
* tests for isolation), all paths are derived from it. Otherwise, the
|
|
* project root is detected via git or cwd.
|
|
*/
|
|
export function resolveConfig(
|
|
env: Record<string, string | undefined> = process.env,
|
|
): BrowseConfig {
|
|
let stateFile: string;
|
|
let stateDir: string;
|
|
let projectDir: string;
|
|
|
|
if (env.BROWSE_STATE_FILE) {
|
|
stateFile = env.BROWSE_STATE_FILE;
|
|
stateDir = path.dirname(stateFile);
|
|
projectDir = path.dirname(stateDir); // parent of .gstack/
|
|
} else {
|
|
projectDir = getGitRoot() || process.cwd();
|
|
stateDir = path.join(projectDir, '.gstack');
|
|
stateFile = path.join(stateDir, 'browse.json');
|
|
}
|
|
|
|
return {
|
|
projectDir,
|
|
stateDir,
|
|
stateFile,
|
|
consoleLog: path.join(stateDir, 'browse-console.log'),
|
|
networkLog: path.join(stateDir, 'browse-network.log'),
|
|
dialogLog: path.join(stateDir, 'browse-dialog.log'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create the .gstack/ state directory if it doesn't exist.
|
|
* Throws with a clear message on permission errors.
|
|
*/
|
|
export function ensureStateDir(config: BrowseConfig): void {
|
|
try {
|
|
fs.mkdirSync(config.stateDir, { recursive: true, mode: 0o700 });
|
|
} catch (err: any) {
|
|
if (err.code === 'EACCES') {
|
|
throw new Error(`Cannot create state directory ${config.stateDir}: permission denied`);
|
|
}
|
|
if (err.code === 'ENOTDIR') {
|
|
throw new Error(`Cannot create state directory ${config.stateDir}: a file exists at that path`);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Ensure .gstack/ is in the project's .gitignore
|
|
const gitignorePath = path.join(config.projectDir, '.gitignore');
|
|
try {
|
|
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
if (!content.match(/^\.gstack\/?$/m)) {
|
|
const separator = content.endsWith('\n') ? '' : '\n';
|
|
fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`);
|
|
}
|
|
} catch (err: any) {
|
|
if (err.code !== 'ENOENT') {
|
|
// Write warning to server log (visible even in daemon mode)
|
|
const logPath = path.join(config.stateDir, 'browse-server.log');
|
|
try {
|
|
fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}\n`);
|
|
} catch {
|
|
// stateDir write failed too — nothing more we can do
|
|
}
|
|
}
|
|
// ENOENT (no .gitignore) — skip silently
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive a slug from the git remote origin URL (owner-repo format).
|
|
* Falls back to the directory basename if no remote is configured.
|
|
*/
|
|
export function getRemoteSlug(): string {
|
|
try {
|
|
const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
timeout: 2_000,
|
|
});
|
|
if (proc.exitCode !== 0) throw new Error('no remote');
|
|
const url = proc.stdout.toString().trim();
|
|
// SSH: git@github.com:owner/repo.git → owner-repo
|
|
// HTTPS: https://github.com/owner/repo.git → owner-repo
|
|
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
if (match) return `${match[1]}-${match[2]}`;
|
|
throw new Error('unparseable');
|
|
} catch {
|
|
const root = getGitRoot();
|
|
return path.basename(root || process.cwd());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the binary version (git SHA) from browse/dist/.version.
|
|
* Returns null if the file doesn't exist or can't be read.
|
|
*/
|
|
export function readVersionHash(execPath: string = process.execPath): string | null {
|
|
try {
|
|
const versionFile = path.resolve(path.dirname(execPath), '.version');
|
|
return fs.readFileSync(versionFile, 'utf-8').trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|