Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## [Unreleased] — security/cookie-auth-hardening (2026-03-30)

### Security

#### 🔐 Daemon Bearer-Token 认证(防本地进程横向移动)

**风险**:Daemon HTTP 端口 `19825` 仅凭 `X-OpenCLI` 自定义头部防御 CSRF。任何本地进程只需加上该头部即可控制浏览器自动化会话,执行任意 JS、读取 Cookie 等高权限操作。

**修复**(`src/daemon.ts`、`src/browser/daemon-client.ts`):
- Daemon 启动时用 `crypto.randomBytes(32)` 生成 64 字符十六进制 Token,写入 `~/.opencli/daemon.token`(权限 `0o600`)。
- 所有非 `/ping` HTTP 端点要求 `Authorization: Bearer <token>` 头部;验证采用恒时比较防时序攻击。
- `daemon-client.ts` 懒加载并缓存 token;收到 `401` 时自动刷新缓存以应对 Daemon 重启。
- Daemon 正常退出时删除 token 文件,避免旧 token 被复用。

#### 🔐 Extension ID 固定(可选加固)

**修复**(`src/daemon.ts`):
- 新增环境变量 `OPENCLI_EXTENSION_ID`:设置后 WebSocket 握手将精确匹配扩展 ID,拒绝其他 `chrome-extension://` 来源。

#### 🍪 HttpOnly Cookie 访问警告 + 脱敏

**风险**:CDP `Network.getCookies` 可读取 `httpOnly` Cookie(session / auth token),而这些 Cookie 正常情况下对 JS 不可见,存在被意外记录或泄露的风险。

**修复**(`src/browser/page.ts`、`src/browser/cdp.ts`、`src/types.ts`):
- 开启 `OPENCLI_VERBOSE=1` 时输出 HttpOnly Cookie 数量警告。
- 新增 `OPENCLI_REDACT_COOKIES=1` 环境变量和 `getCookies({ redact: true })` 选项,自动将 HttpOnly Cookie 及敏感名称(`session`、`token`、`auth`、`jwt` 等)的值替换为 `[REDACTED]`。
- 新增 `isSensitiveCookieName()`、`redactCookies()` 工具函数供下游复用。

#### 🌐 CDP Endpoint 强制 Localhost 校验

**风险**:`OPENCLI_CDP_ENDPOINT` 无主机校验,若指向远程地址将把浏览器 Cookie 和 DOM 数据暴露给第三方。

**修复**(`src/browser/cdp.ts`):
- 连接前校验主机名,仅允许 `localhost`、`127.0.0.1`、`::1`。
- 如需连接远程实例(高级调试),可设置 `OPENCLI_CDP_ALLOW_REMOTE=1` 解除限制。

#### ⚠️ Pipeline fetch 步骤 `credentials: "include"` 安全注释

**修复**(`src/pipeline/steps/fetch.ts`):
- 添加注释说明 `credentials: "include"` 的预期用途(登录态 API 抓取)及风险(不可传入不受信任的 URL),防止误用导致 CSRF。


## [1.4.1](https://github.com/jackwener/opencli/compare/v1.4.0...v1.4.1) (2026-03-25)


Expand Down
58 changes: 57 additions & 1 deletion src/browser/cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ const { MockWebSocket } = vi.hoisted(() => {
static OPEN = 1;
readyState = 1;
private handlers = new Map<string, Array<(...args: any[]) => void>>();
sent: any[] = [];
static instances: MockWebSocket[] = [];

constructor(_url: string) {
MockWebSocket.instances.push(this);
queueMicrotask(() => this.emit('open'));
}

Expand All @@ -16,7 +19,32 @@ const { MockWebSocket } = vi.hoisted(() => {
this.handlers.set(event, handlers);
}

send(_message: string): void {}
send(message: string): void {
const payload = JSON.parse(message);
this.sent.push(payload);

if (payload.method === 'Target.createTarget') {
queueMicrotask(() => this.emit('message', JSON.stringify({
id: payload.id,
result: { targetId: 'target-1' },
})));
return;
}

if (payload.method === 'Target.attachToTarget') {
queueMicrotask(() => this.emit('message', JSON.stringify({
id: payload.id,
result: { sessionId: 'session-1' },
})));
return;
}

queueMicrotask(() => this.emit('message', JSON.stringify({
id: payload.id,
result: {},
sessionId: payload.sessionId,
})));
}

close(): void {
this.readyState = 3;
Expand All @@ -41,6 +69,7 @@ import { CDPBridge } from './cdp.js';
describe('CDPBridge cookies', () => {
beforeEach(() => {
vi.unstubAllEnvs();
MockWebSocket.instances.length = 0;
});

it('filters cookies by actual domain match instead of substring match', async () => {
Expand All @@ -63,4 +92,31 @@ describe('CDPBridge cookies', () => {
{ name: 'exact', value: '2', domain: 'example.com' },
]);
});

it('attaches to a browser-level websocket endpoint and scopes page commands to the target session', async () => {
vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/browser/browser-1');

const bridge = new CDPBridge();
await bridge.connect();

const sent = MockWebSocket.instances[0]?.sent ?? [];
expect(sent.map((item) => item.method)).toEqual([
'Target.createTarget',
'Target.attachToTarget',
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
]);
expect(sent[1]).toMatchObject({
method: 'Target.attachToTarget',
params: { targetId: 'target-1', flatten: true },
});
expect(sent[2]).toMatchObject({
method: 'Page.enable',
sessionId: 'session-1',
});
expect(sent[3]).toMatchObject({
method: 'Page.addScriptToEvaluateOnNewDocument',
sessionId: 'session-1',
});
});
});
94 changes: 87 additions & 7 deletions src/browser/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { WebSocket, type RawData } from 'ws';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { redactCookies } from '../types.js';
import type { IBrowserFactory } from '../runtime.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
Expand Down Expand Up @@ -55,14 +56,24 @@ export class CDPBridge implements IBrowserFactory {
private _idCounter = 0;
private _pending = new Map<number, { resolve: (val: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
private _eventListeners = new Map<string, Set<(params: unknown) => void>>();
private _sessionId: string | null = null;

async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.');

const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');

// ── Security: enforce localhost-only CDP connections ─────────────
// Connecting to a remote host would expose all browser cookies and DOM
// content to a third party. Refuse unless OPENCLI_CDP_ALLOW_REMOTE=1
// is explicitly set (power-users who need remote debugging).
if (process.env.OPENCLI_CDP_ALLOW_REMOTE !== '1') {
assertLocalhostEndpoint(endpoint);
}

let wsUrl = endpoint;
const isBrowserEndpoint = /^wss?:\/\/.+\/devtools\/browser\//i.test(endpoint);
if (endpoint.startsWith('http')) {
const targets = await fetchJsonDirect(`${endpoint.replace(/\/$/, '')}/json`) as CDPTarget[];
const target = selectCDPTarget(targets);
Expand All @@ -81,6 +92,19 @@ export class CDPBridge implements IBrowserFactory {
clearTimeout(timeout);
this._ws = ws;
try {
if (isBrowserEndpoint) {
const target = await this.sendRaw('Target.createTarget', { url: 'about:blank' }) as { targetId?: string };
const targetId = typeof target?.targetId === 'string' ? target.targetId : '';
if (!targetId) throw new Error('CDP browser endpoint did not return a targetId');

const attached = await this.sendRaw('Target.attachToTarget', {
targetId,
flatten: true,
}) as { sessionId?: string };
const sessionId = typeof attached?.sessionId === 'string' ? attached.sessionId : '';
if (!sessionId) throw new Error('CDP browser endpoint did not return a sessionId');
this._sessionId = sessionId;
}
await this.send('Page.enable');
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
} catch {}
Expand All @@ -105,7 +129,7 @@ export class CDPBridge implements IBrowserFactory {
entry.resolve(msg.result);
}
}
if (msg.method) {
if (msg.method && (!this._sessionId || !msg.sessionId || msg.sessionId === this._sessionId)) {
const listeners = this._eventListeners.get(msg.method);
if (listeners) {
for (const fn of listeners) fn(msg.params);
Expand All @@ -130,6 +154,15 @@ export class CDPBridge implements IBrowserFactory {
}

async send(method: string, params: Record<string, unknown> = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<unknown> {
return this.sendRaw(method, params, timeoutMs, this._sessionId ?? undefined);
}

private async sendRaw(
method: string,
params: Record<string, unknown> = {},
timeoutMs: number = CDP_SEND_TIMEOUT,
sessionId?: string,
): Promise<unknown> {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('CDP connection is not open');
}
Expand All @@ -140,7 +173,7 @@ export class CDPBridge implements IBrowserFactory {
reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`));
}, timeoutMs);
this._pending.set(id, { resolve, reject, timer });
this._ws!.send(JSON.stringify({ id, method, params }));
this._ws!.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
});
}

Expand Down Expand Up @@ -206,13 +239,27 @@ class CDPPage implements IPage {
return result.result?.value;
}

async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
async getCookies(opts: { domain?: string; url?: string; redact?: boolean } = {}): Promise<BrowserCookie[]> {
const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {});
const cookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : [];
const rawCookies = isRecord(result) && Array.isArray(result.cookies) ? result.cookies : [];
const domain = opts.domain;
return domain
? cookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && matchesCookieDomain(cookie.domain, domain))
: cookies;
const cookies: BrowserCookie[] = domain
? rawCookies.filter((cookie): cookie is BrowserCookie => isCookie(cookie) && matchesCookieDomain(cookie.domain, domain))
: rawCookies.filter(isCookie);

// CDP Network.getCookies exposes HttpOnly cookies — warn operators.
const httpOnlyCount = cookies.filter((c) => c.httpOnly).length;
if (httpOnlyCount > 0 && process.env.OPENCLI_VERBOSE) {
console.error(
`[opencli] Warning: getCookies() returned ${httpOnlyCount} HttpOnly cookie(s) via CDP.` +
' These may contain session tokens — avoid logging or storing raw values.',
);
}

if (opts.redact || process.env.OPENCLI_REDACT_COOKIES === '1') {
return redactCookies(cookies);
}
return cookies;
}

async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
Expand Down Expand Up @@ -436,6 +483,39 @@ export const __test__ = {
scoreCDPTarget,
};

/**
* Verify that the CDP endpoint resolves to a loopback address.
* This prevents accidental (or malicious) connections to remote hosts,
* which would expose all browser cookies and DOM state to a third party.
*
* Allowed: http://localhost:*, http://127.0.0.1:*, http://[::1]:*
* ws://localhost:*, ws://127.0.0.1:*
* Blocked: anything else unless OPENCLI_CDP_ALLOW_REMOTE=1
*/
function assertLocalhostEndpoint(endpoint: string): void {
let hostname: string;
try {
const url = new URL(endpoint);
hostname = url.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
} catch {
// If it's not a valid URL (e.g. bare ws:// fragment), do a string check
hostname = endpoint;
}

const LOOPBACK = ['localhost', '127.0.0.1', '::1', '0:0:0:0:0:0:0:1'];
const isLoopback = LOOPBACK.some(
(h) => hostname === h || hostname.toLowerCase() === h,
);

if (!isLoopback) {
throw new Error(
`Security: OPENCLI_CDP_ENDPOINT "${endpoint}" points to a non-loopback host.` +
' Connecting to a remote CDP endpoint exposes all browser cookies and DOM data.' +
' Set OPENCLI_CDP_ALLOW_REMOTE=1 to override (advanced users only).',
);
}
}

function fetchJsonDirect(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
Expand Down
Loading