add security hardening features (#543)
* add security hardening features - Add authentication vault (`auth save/login/list/show/delete`) so credentials are stored locally and never exposed to the LLM (fixes Snyk W007) - Add `--content-boundaries` flag to wrap page-sourced output in structural markers, helping LLMs distinguish tool output from untrusted page content (fixes Snyk W011) - Add `--allowed-domains` flag to restrict browser navigation to trusted domains - Add `--action-policy` for static allow/deny gating of action categories, with opt-in `--confirm-actions`/`--confirm-interactive` for orchestrator or human-in-the-loop confirmation - Add `--max-output` flag to truncate large page outputs, preventing context flooding - New docs page at /security, updated README, SKILL.md, CLI help text, and templates * fixes * fixes * fixes * fixes * fixes * fixes * fixes * docs
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
ensureEncryptionKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
isEncryptedPayload,
|
||||
getKeyFilePath,
|
||||
restrictFilePermissions,
|
||||
restrictDirPermissions,
|
||||
type EncryptedPayload,
|
||||
} from './encryption.js';
|
||||
|
||||
const AUTH_DIR = 'auth';
|
||||
|
||||
interface AuthProfile {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthProfileMeta {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
function getAuthDir(): string {
|
||||
const dir = path.join(os.homedir(), '.agent-browser', AUTH_DIR);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
restrictDirPermissions(dir);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function validateProfileName(name: string): void {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid auth profile name '${name}': only alphanumeric characters, hyphens, and underscores are allowed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function profilePath(name: string): string {
|
||||
validateProfileName(name);
|
||||
return path.join(getAuthDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
function readProfile(name: string): AuthProfile | null {
|
||||
const p = profilePath(name);
|
||||
if (!existsSync(p)) return null;
|
||||
|
||||
const raw = readFileSync(p, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (isEncryptedPayload(parsed)) {
|
||||
const key = getEncryptionKey();
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Encryption key required to read encrypted auth profiles. ` +
|
||||
`Set AGENT_BROWSER_ENCRYPTION_KEY or ensure ${getKeyFilePath()} exists.`
|
||||
);
|
||||
}
|
||||
const decrypted = decryptData(parsed as EncryptedPayload, key);
|
||||
return JSON.parse(decrypted) as AuthProfile;
|
||||
}
|
||||
|
||||
return parsed as AuthProfile;
|
||||
}
|
||||
|
||||
function writeProfile(profile: AuthProfile): void {
|
||||
const key = ensureEncryptionKey();
|
||||
const serialized = JSON.stringify(profile, null, 2);
|
||||
const encrypted = encryptData(serialized, key);
|
||||
const filePath = profilePath(profile.name);
|
||||
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
restrictFilePermissions(filePath);
|
||||
}
|
||||
|
||||
export function saveAuthProfile(opts: {
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
usernameSelector?: string;
|
||||
passwordSelector?: string;
|
||||
submitSelector?: string;
|
||||
}): AuthProfileMeta & { updated: boolean } {
|
||||
const existing = readProfile(opts.name);
|
||||
|
||||
const profile: AuthProfile = {
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
usernameSelector: opts.usernameSelector,
|
||||
passwordSelector: opts.passwordSelector,
|
||||
submitSelector: opts.submitSelector,
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
lastLoginAt: existing?.lastLoginAt,
|
||||
};
|
||||
|
||||
writeProfile(profile);
|
||||
|
||||
return {
|
||||
name: profile.name,
|
||||
url: profile.url,
|
||||
username: profile.username,
|
||||
createdAt: profile.createdAt,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
updated: existing !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthProfile(name: string): AuthProfile | null {
|
||||
return readProfile(name);
|
||||
}
|
||||
|
||||
export function getAuthProfileMeta(name: string): AuthProfileMeta | null {
|
||||
const profile = readProfile(name);
|
||||
if (!profile) return null;
|
||||
return {
|
||||
name: profile.name,
|
||||
url: profile.url,
|
||||
username: profile.username,
|
||||
createdAt: profile.createdAt,
|
||||
lastLoginAt: profile.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listAuthProfiles(): AuthProfileMeta[] {
|
||||
const dir = getAuthDir();
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
||||
const profiles: AuthProfileMeta[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.replace(/\.json$/, '');
|
||||
try {
|
||||
const meta = getAuthProfileMeta(name);
|
||||
if (meta) profiles.push(meta);
|
||||
} catch {
|
||||
profiles.push({
|
||||
name,
|
||||
url: '(encrypted)',
|
||||
username: '(encrypted)',
|
||||
createdAt: '(unknown)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
export function deleteAuthProfile(name: string): boolean {
|
||||
const p = profilePath(name);
|
||||
if (!existsSync(p)) return false;
|
||||
unlinkSync(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateLastLogin(name: string): void {
|
||||
const profile = readProfile(name);
|
||||
if (profile) {
|
||||
profile.lastLoginAt = new Date().toISOString();
|
||||
writeProfile(profile);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user