port: apply 7 upstream security/robustness patches
Ports from isolated upstream-port branch (base b3c5a4b), verified in
isolation via baseline-vs-port failure-set diff and re-verified live
(195 pass / 0 fail on affected tests):
- redact Discord bot tokens in outbound (router.ts SECRET_PATTERNS)
- block SSRF to private hosts in MoA base URL (moa.ts)
- refuse public dashboard bind without auth token (web-dashboard-server.ts)
- merge upstream .gitignore rules for python/build/secret noise
- real CPU utilization from /proc/stat instead of load avg (unified-dashboard.ts)
- width-safe placeholder for missing usage window on mobile (unified-dashboard.ts)
- bump direct deps to patch known vulnerabilities (discord.js/yaml/cron-parser)
Risky upstream commits (d5a94af phantom reset-time, patch 6 Codex usage)
intentionally skipped to avoid touching the credential-isolation tree.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -318,4 +318,14 @@ describe('formatOutbound with tool-call leaks', () => {
|
||||
'Key: sk-ant-XXXXXXXXXXXXXXXXXXXXXXXX\nto=functions.exec_command code {"cmd":"ls"}';
|
||||
expect(formatOutbound(input)).toBe('Key: [REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts a leaked Discord bot token', () => {
|
||||
// Built from parts so the literal never appears in source (push protection).
|
||||
const fakeToken = [
|
||||
'MTA5ODc2NTQzMjEwOTg3NjU0',
|
||||
'GaBcDe',
|
||||
'abcdefghijklmnopqrstuvwxyz0123',
|
||||
].join('.');
|
||||
expect(formatOutbound(`token=${fakeToken}`)).toBe('token=[REDACTED]');
|
||||
});
|
||||
});
|
||||
|
||||
48
src/moa.test.ts
Normal file
48
src/moa.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { assertSafeMoaBaseUrl } from './moa.js';
|
||||
|
||||
describe('assertSafeMoaBaseUrl', () => {
|
||||
it('accepts public https endpoints', () => {
|
||||
expect(() =>
|
||||
assertSafeMoaBaseUrl('https://api.moonshot.cn/v1'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertSafeMoaBaseUrl('https://open.bigmodel.cn/api/paas/v4'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-http(s) schemes', () => {
|
||||
expect(() => assertSafeMoaBaseUrl('file:///etc/passwd')).toThrow(
|
||||
/http\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects loopback and localhost', () => {
|
||||
expect(() => assertSafeMoaBaseUrl('http://127.0.0.1:8080')).toThrow(
|
||||
/not allowed/,
|
||||
);
|
||||
expect(() => assertSafeMoaBaseUrl('http://localhost/v1')).toThrow(
|
||||
/not allowed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects cloud metadata and private ranges', () => {
|
||||
expect(() => assertSafeMoaBaseUrl('http://169.254.169.254/latest')).toThrow(
|
||||
/not allowed/,
|
||||
);
|
||||
expect(() => assertSafeMoaBaseUrl('http://10.0.0.5/v1')).toThrow(
|
||||
/not allowed/,
|
||||
);
|
||||
expect(() => assertSafeMoaBaseUrl('http://192.168.1.1/v1')).toThrow(
|
||||
/not allowed/,
|
||||
);
|
||||
expect(() => assertSafeMoaBaseUrl('http://100.101.210.95/v1')).toThrow(
|
||||
/not allowed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
expect(() => assertSafeMoaBaseUrl('not-a-url')).toThrow(/Invalid/);
|
||||
});
|
||||
});
|
||||
58
src/moa.ts
58
src/moa.ts
@@ -44,6 +44,63 @@ function normalizeError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject MoA base URLs that point at loopback / private / link-local hosts.
|
||||
*
|
||||
* MoA reference models are external SaaS endpoints (Kimi, GLM, ...). Allowing
|
||||
* an internal host lets a compromised or misconfigured settings write turn the
|
||||
* server-side MoA fetch into an SSRF probe (e.g. cloud metadata at
|
||||
* 169.254.169.254). Only http(s) to a non-private host is permitted.
|
||||
*/
|
||||
export function assertSafeMoaBaseUrl(baseUrl: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid MoA base URL: ${baseUrl}`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error(`MoA base URL must use http(s): ${baseUrl}`);
|
||||
}
|
||||
if (isPrivateOrLocalHost(parsed.hostname)) {
|
||||
throw new Error(
|
||||
`MoA base URL host is not allowed (loopback/private/link-local): ${parsed.hostname}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isPrivateOrLocalHost(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
if (
|
||||
host === 'localhost' ||
|
||||
host.endsWith('.localhost') ||
|
||||
host.endsWith('.internal') ||
|
||||
host.endsWith('.local')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// IPv6 loopback / link-local / unique-local
|
||||
if (host === '::1' || host === '::') return true;
|
||||
if (
|
||||
host.startsWith('fe80:') ||
|
||||
host.startsWith('fc') ||
|
||||
host.startsWith('fd')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
||||
if (a === 127 || a === 0 || a === 10) return true; // loopback / this-host / private
|
||||
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
||||
if (a === 192 && b === 168) return true; // private
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT / tailnet
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function recordReferenceStatus(
|
||||
status: Omit<MoaReferenceStatus, 'checkedAt'>,
|
||||
): MoaReferenceStatus {
|
||||
@@ -69,6 +126,7 @@ async function queryModel(
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
assertSafeMoaBaseUrl(model.baseUrl);
|
||||
const base = model.baseUrl.replace(/\/+$/, '');
|
||||
const isAnthropic = model.apiFormat === 'anthropic';
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ const SECRET_PATTERNS: RegExp[] = [
|
||||
/glpat-[A-Za-z0-9_-]{20,}/g, // GitLab PAT
|
||||
/AKIA[A-Z0-9]{16}/g, // AWS Access Key
|
||||
/Bearer\s+eyJ[A-Za-z0-9_-]{40,}/g, // Bearer JWT
|
||||
// Discord bot token: base64 id "." 6-char ts "." 27+ char hmac
|
||||
/\b[MN][A-Za-z0-9_-]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/g,
|
||||
];
|
||||
|
||||
function redactSecrets(text: string): string {
|
||||
|
||||
@@ -8,11 +8,38 @@ import {
|
||||
formatStatusHeader,
|
||||
msUntilNextMinuteBoundary,
|
||||
getDashboardDuplicateCleanupIntervalMs,
|
||||
readCpuUtilizationPct,
|
||||
renderUsageTable,
|
||||
resetCpuUtilizationSample,
|
||||
shouldPurgeDashboardChannelOnStart,
|
||||
summarizeWatcherTasks,
|
||||
} from './unified-dashboard.js';
|
||||
|
||||
describe('readCpuUtilizationPct', () => {
|
||||
it('computes busy percentage from consecutive /proc/stat samples', () => {
|
||||
resetCpuUtilizationSample();
|
||||
expect(
|
||||
readCpuUtilizationPct(() => 'cpu 100 0 100 800 0 0 0 0 0 0\n'),
|
||||
).toBeNull();
|
||||
// +100 total ticks: +30 busy, +70 idle => 30%.
|
||||
expect(
|
||||
readCpuUtilizationPct(() => 'cpu 115 0 115 870 0 0 0 0 0 0\n'),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
it('counts iowait as idle and rejects malformed samples', () => {
|
||||
resetCpuUtilizationSample();
|
||||
expect(readCpuUtilizationPct(() => 'not-a-cpu-line\n')).toBeNull();
|
||||
expect(
|
||||
readCpuUtilizationPct(() => 'cpu 100 0 100 700 100 0 0 0 0 0\n'),
|
||||
).toBeNull();
|
||||
// +100 total, all +100 is iowait: CPU busy must be 0%, not 100%.
|
||||
expect(
|
||||
readCpuUtilizationPct(() => 'cpu 100 0 100 700 200 0 0 0 0 0\n'),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeWatcherTasks', () => {
|
||||
it('counts active and paused watcher tasks only', () => {
|
||||
const summary = summarizeWatcherTasks([
|
||||
|
||||
@@ -488,6 +488,47 @@ function buildStatusContent(): string {
|
||||
return `${header}\n\n${sections}`;
|
||||
}
|
||||
|
||||
/** Previous /proc/stat sample for CPU utilization deltas. */
|
||||
let lastCpuSample: { idle: number; total: number } | null = null;
|
||||
|
||||
/**
|
||||
* Real CPU utilization percent from /proc/stat deltas between calls.
|
||||
* First call (no previous sample) and non-Linux hosts return null.
|
||||
* Exported for testing alongside resetCpuUtilizationSample.
|
||||
*/
|
||||
export function readCpuUtilizationPct(
|
||||
readStat: () => string = () => fs.readFileSync('/proc/stat', 'utf-8'),
|
||||
): number | null {
|
||||
try {
|
||||
const cpuLine = readStat()
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('cpu '));
|
||||
if (!cpuLine) return null;
|
||||
const fields = cpuLine.trim().split(/\s+/).slice(1).map(Number);
|
||||
if (fields.length < 5 || fields.some((n) => !Number.isFinite(n))) {
|
||||
return null;
|
||||
}
|
||||
// user nice system idle iowait irq softirq steal ...
|
||||
const idle = fields[3] + (fields[4] ?? 0); // idle + iowait
|
||||
const total = fields.reduce((a, b) => a + b, 0);
|
||||
const prev = lastCpuSample;
|
||||
lastCpuSample = { idle, total };
|
||||
if (!prev || total <= prev.total) return null;
|
||||
const totalDelta = total - prev.total;
|
||||
const idleDelta = idle - prev.idle;
|
||||
return Math.round(
|
||||
Math.min(100, Math.max(0, ((totalDelta - idleDelta) / totalDelta) * 100)),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: reset the CPU sample so the next read starts fresh. */
|
||||
export function resetCpuUtilizationSample(): void {
|
||||
lastCpuSample = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render usage table lines from two row groups (Claude and Codex).
|
||||
* Returns rendered lines including code block markers.
|
||||
@@ -517,6 +558,10 @@ export function renderUsageTable(
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Missing-window placeholder. Must mirror a real cell's char composition
|
||||
// (5 ambiguous-width bar chars + 4 ASCII): mobile fonts render block/box
|
||||
// chars double-width, so a plain '—' cell breaks column alignment.
|
||||
const emptyCell = '─'.repeat(5) + ' ';
|
||||
const renderRows = (rows: UsageRow[]) => {
|
||||
for (const row of rows) {
|
||||
if (row.error) {
|
||||
@@ -529,11 +574,11 @@ export function renderUsageTable(
|
||||
const h5 =
|
||||
row.h5pct >= 0
|
||||
? `${bar(row.h5pct)}${String(row.h5pct).padStart(3)}%`
|
||||
: ' — ';
|
||||
: emptyCell;
|
||||
const d7 =
|
||||
row.d7pct >= 0
|
||||
? `${bar(row.d7pct)}${String(row.d7pct).padStart(3)}%`
|
||||
: ' — ';
|
||||
: emptyCell;
|
||||
lines.push(`${padName(row.name)}${h5} ${d7}`);
|
||||
const r5 = compactReset(row.h5reset);
|
||||
const r7 = compactReset(row.d7reset);
|
||||
@@ -626,7 +671,11 @@ async function buildUsageContent(): Promise<string> {
|
||||
|
||||
const loadAvg = os.loadavg();
|
||||
const cpuCount = os.cpus().length;
|
||||
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
|
||||
// Real CPU utilization from /proc/stat deltas between renders.
|
||||
// Load-average-based percent counts D-state (I/O-wait) processes, so a
|
||||
// writeback storm once rendered as "CPU 3104%" despite idle CPUs.
|
||||
const cpuPct = readCpuUtilizationPct() ?? 0;
|
||||
const loadPerCore = loadAvg[0] / cpuCount;
|
||||
const totalMem = os.totalmem();
|
||||
// os.freemem() includes buffers/cache as "used" — misleading.
|
||||
// Read MemAvailable from /proc/meminfo for actual available memory.
|
||||
@@ -696,6 +745,12 @@ async function buildUsageContent(): Promise<string> {
|
||||
|
||||
lines.push('```');
|
||||
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
|
||||
// Raw load average with core count: I/O storms (D-state pileups) show up
|
||||
// here without masquerading as CPU usage. Flag when load exceeds cores.
|
||||
const loadFlag = loadPerCore > 1 ? ' ▲' : '';
|
||||
lines.push(
|
||||
`${'Load'.padEnd(8)}${loadAvg[0] >= 100 ? loadAvg[0].toFixed(0) : loadAvg[0].toFixed(1)}/${cpuCount}cpu${loadFlag}`,
|
||||
);
|
||||
lines.push(
|
||||
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,10 @@ import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { NewMessage, PairedTask, RegisteredGroup } from './types.js';
|
||||
import { createWebDashboardHandler } from './web-dashboard-server.js';
|
||||
import {
|
||||
assertDashboardAuthPosture,
|
||||
createWebDashboardHandler,
|
||||
} from './web-dashboard-server.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -45,6 +48,36 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('assertDashboardAuthPosture', () => {
|
||||
it('allows loopback binding without a token', () => {
|
||||
expect(() => assertDashboardAuthPosture('127.0.0.1', '')).not.toThrow();
|
||||
expect(() => assertDashboardAuthPosture('localhost', '')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows any host when a token is set', () => {
|
||||
expect(() => assertDashboardAuthPosture('0.0.0.0', 'secret')).not.toThrow();
|
||||
expect(() =>
|
||||
assertDashboardAuthPosture('203.0.113.5', 'secret'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('warns but starts on a private / tailnet host without a token', () => {
|
||||
expect(() =>
|
||||
assertDashboardAuthPosture('100.101.210.95', ''),
|
||||
).not.toThrow();
|
||||
expect(() => assertDashboardAuthPosture('192.168.1.10', '')).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a public / all-interface bind without a token', () => {
|
||||
expect(() => assertDashboardAuthPosture('0.0.0.0', '')).toThrow(
|
||||
/WEB_DASHBOARD_TOKEN/,
|
||||
);
|
||||
expect(() => assertDashboardAuthPosture('203.0.113.5', '')).toThrow(
|
||||
/WEB_DASHBOARD_TOKEN/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web dashboard server handler', () => {
|
||||
it('serves health and overview JSON without requiring Discord', async () => {
|
||||
const handler = createWebDashboardHandler({
|
||||
|
||||
@@ -504,6 +504,54 @@ export function createWebDashboardHandler(
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
return h === 'localhost' || h === '::1' || /^127\./.test(h);
|
||||
}
|
||||
|
||||
function isPrivateBindHost(host: string): boolean {
|
||||
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) {
|
||||
return true;
|
||||
}
|
||||
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\./);
|
||||
if (!ipv4) return false;
|
||||
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
||||
if (a === 10) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT / tailnet
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to expose an unauthenticated dashboard on a public interface.
|
||||
*
|
||||
* With no token the API auth gate passes for everyone (see
|
||||
* isDashboardApiAuthorized). Binding that to a public interface / all
|
||||
* interfaces (0.0.0.0) would let any reachable client drive agents and restart
|
||||
* the service, so we fail fast. Loopback is fine; private / tailnet binds warn
|
||||
* loudly but still start so existing deployments keep running.
|
||||
*/
|
||||
export function assertDashboardAuthPosture(host: string, token: string): void {
|
||||
if (token) return;
|
||||
if (isLoopbackHost(host)) return;
|
||||
if (isPrivateBindHost(host)) {
|
||||
logger.warn(
|
||||
{ host },
|
||||
'Web dashboard is bound to a non-loopback host without WEB_DASHBOARD_TOKEN; ' +
|
||||
'anyone who can reach this host can drive agents and restart the service. ' +
|
||||
'Set WEB_DASHBOARD_TOKEN to require authentication.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Refusing to start web dashboard on public host "${host}" without WEB_DASHBOARD_TOKEN. ` +
|
||||
'Set WEB_DASHBOARD_TOKEN or bind to 127.0.0.1.',
|
||||
);
|
||||
}
|
||||
|
||||
export function startWebDashboardServer(
|
||||
opts: {
|
||||
enabled?: boolean;
|
||||
@@ -521,6 +569,7 @@ export function startWebDashboardServer(
|
||||
const host = opts.host ?? WEB_DASHBOARD.host;
|
||||
const port = opts.port ?? WEB_DASHBOARD.port;
|
||||
const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir;
|
||||
assertDashboardAuthPosture(host, WEB_DASHBOARD.token);
|
||||
const server = Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
|
||||
Reference in New Issue
Block a user