sync: update from private repo (e62f5c7)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 01:52:48 +00:00
parent 000a2474aa
commit d061ad08d8
237 changed files with 8441 additions and 5549 deletions
+43
View File
@@ -13,6 +13,7 @@ import type { AuthConfig, AuthProviderConfig } from '../config.js';
import type { Repository, User } from '../db/repository.js';
import { logger } from '../logger.js';
import { randomBytes } from 'crypto';
import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js';
/**
* WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。
@@ -598,6 +599,25 @@ function createAuthRouter(
if (isLocalEnabled(authConfig)) {
const parseBody = [express.urlencoded({ extended: false }), express.json()];
// Throttle online password guessing per client IP: lock for 15 min after
// 10 failures within 15 min. scrypt already makes each guess costly; this
// caps the rate. Keyed by IP only — NOT by account — on purpose: account
// lockout would let anyone lock a victim out by failing on their email
// (targeted DoS), and would let unauthenticated callers grow the key map
// with arbitrary email strings (memory DoS). Process-local; a multi-process
// / HA deployment behind a load balancer would need a shared store.
const loginLimiter = new LoginRateLimiter({
maxAttempts: 10,
windowMs: 15 * 60 * 1000,
lockoutMs: 15 * 60 * 1000,
maxKeys: 10_000,
});
// `req.ip` honors Express `trust proxy` (set when secureCookie is on, i.e.
// behind a TLS proxy). Falls back to the raw socket address otherwise.
const clientIp = (req: Request): string => req.ip ?? req.socket?.remoteAddress ?? 'unknown';
// Keep raw user input out of log lines (strip control chars, cap length).
const logSafe = (s: string): string => s.replace(/[^\x20-\x7e]/g, '').slice(0, 120);
const toExpressUser = (u: User): Express.User => ({
...u,
orgIds: resolveOrgIds(repo, u.id),
@@ -617,14 +637,29 @@ function createAuthRouter(
router.post('/local', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
const creds = readCreds(req);
if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
const ip = clientIp(req);
// Key on the /64 for IPv6 so a client holding a whole /64 can't rotate
// source addresses to evade the throttle (raw `ip` is kept for logs).
const ipKey = `ip:${throttleScopeForIp(ip)}`;
if (loginLimiter.isLocked(ipKey)) {
logger.warn(`[auth] local login throttled ip=${logSafe(ip)}`);
res.redirect('/auth/login?error=locked');
return;
}
const user = repo.getUserByEmail(creds.email);
// Verify even when the user is missing is not needed; getUserByEmail is the
// gate. Wrong password and unknown email both surface as the same error.
if (!user || !repo.verifyLocalPassword(user.id, creds.password)) {
loginLimiter.recordFailure(ipKey);
logger.warn(`[auth] local login failed ip=${logSafe(ip)} email=${logSafe(creds.email)}`);
res.redirect('/auth/login?error=credentials');
return;
}
if (user.status === 'disabled') { res.redirect('/auth/login?error=disabled'); return; }
// Clear the throttle on success. req.login (passport SessionManager.logIn)
// regenerates the session id itself, so no explicit regenerate is needed
// here for session-fixation protection.
loginLimiter.reset(ipKey);
req.login(toExpressUser(user), (err) => {
if (err) { next(err); return; }
res.redirect(user.status === 'active' ? '/' : '/auth/pending');
@@ -645,6 +680,8 @@ function createAuthRouter(
res.redirect('/auth/login?error=signup');
return;
}
// req.login regenerates the session id (passport SessionManager.logIn),
// which is the session-fixation protection for this manual flow.
req.login(toExpressUser(user), (err) => {
if (err) { next(err); return; }
res.redirect('/auth/pending');
@@ -714,6 +751,12 @@ export function setupAuth(
saveUninitialized: false,
store: createSqliteSessionStore(db),
cookie: {
// httpOnly: keep the session cookie out of document.cookie (defense in
// depth against XSS token theft). sameSite 'lax': don't send the cookie on
// cross-site POST/DELETE, which blocks the basic CSRF vector without a
// token for this same-origin app.
httpOnly: true,
sameSite: 'lax',
secure: authConfig.secureCookie,
maxAge: authConfig.sessionMaxAge,
},
+18
View File
@@ -146,6 +146,24 @@ describe('Branding API', () => {
expect(get.body.logoUrl).toBe(upload.body.url);
});
it('serves /branding assets with CSP sandbox + nosniff headers (SVG XSS hardening)', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
// SVG with an embedded script — allowed by the extension allowlist
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';
const upload = await request(app)
.post('/api/branding/upload')
.send({ kind: 'logo', filename: 'logo.svg', contentBase64: Buffer.from(svg).toString('base64') });
expect(upload.status).toBe(200);
const asset = await request(app).get(upload.body.url);
expect(asset.status).toBe(200);
expect(asset.headers['content-security-policy']).toBe('sandbox');
expect(asset.headers['x-content-type-options']).toBe('nosniff');
// Content-Type must stay image/svg+xml so <img src> rendering still works
expect(asset.headers['content-type']).toMatch(/image\/svg\+xml/);
});
it('rejects invalid kind', async () => {
const { app } = makeApp('provider:\n model: test-model\n', true);
const res = await request(app)
+6
View File
@@ -5,6 +5,7 @@ import { join, extname, basename } from 'path';
import { randomBytes } from 'crypto';
import type { ConfigManager } from '../config-manager.js';
import { logger } from '../logger.js';
import { setUntrustedFileResponseHeaders } from './local-api-helpers.js';
export interface PublicBranding {
appName: string;
@@ -129,9 +130,14 @@ export function mountBrandingApi(
// Serve uploaded assets. Directory is created lazily if first write happens;
// express.static handles the not-exists case by falling through to 404.
// Assets are admin-uploaded and the allowlist includes .svg, which can embed
// scripts: navigating to /branding/logo.svg directly would otherwise run that
// script on the app origin (admin stored XSS). `CSP: sandbox` + nosniff
// neutralize this while leaving <img src> subresource rendering untouched.
app.use('/branding', express.static(brandingDir, {
maxAge: '7d',
fallthrough: true,
setHeaders: (res) => setUntrustedFileResponseHeaders(res),
}));
// Upload endpoint: admin only. Body is JSON with base64 content
+31 -1
View File
@@ -15,6 +15,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createConsoleSessionRouter } from './console-ws-api.js';
import { finalizeServer } from './server.js';
import { preflight, type SshSubsystem } from '../engine/tools/ssh.js';
import { SessionRegistry } from '../ssh/console-registry.js';
import type { SimpleTask, SimpleUser } from './console-ws-api.js';
@@ -177,9 +178,10 @@ function buildApp(opts: {
if (user) {
app.use((req, _res, next) => { (req as any).user = user; next(); });
}
// Mirrors the production mount in server.ts: NO mount-level express.json()
// — the session route carries its own scoped parser.
app.use(
'/api',
express.json(),
createConsoleSessionRouter({
sub: opts.sub,
preflight,
@@ -193,6 +195,34 @@ function buildApp(opts: {
describe('POST /api/local/tasks/:taskId/console/session', () => {
beforeEach(() => vi.clearAllMocks());
it('does not intercept large JSON bodies destined for other /api routes (issue: PR #431 mounted a 100kb parser on all of /api)', async () => {
const h = mkSub();
const app = buildApp({ sub: h.sub });
// Downstream route with its own large-limit parser, mounted after the
// console router — mirrors the /api/local/tasks attachment upload path.
app.post('/api/local/tasks', express.json({ limit: '5mb' }), (req, res) => {
res.json({ received: (req.body?.attachments?.[0]?.contentBase64 ?? '').length });
});
finalizeServer(app); // production 404 + error handlers
const big = 'a'.repeat(300 * 1024); // well over express.json's 100kb default
const res = await request(app)
.post('/api/local/tasks')
.send({ attachments: [{ name: 'x.pdf', contentBase64: big }] });
expect(res.status).toBe(200);
expect(res.body.received).toBe(big.length);
});
it('rejects oversized bodies on the session route itself with 413 (scoped 4kb limit + production error handler)', async () => {
const h = mkSub();
const app = buildApp({ sub: h.sub });
finalizeServer(app); // production error handler must keep this a 413, not a generic 500
const res = await request(app)
.post('/api/local/tasks/1/console/session')
.send({ connection_id: 'conn-1', padding: 'x'.repeat(8 * 1024) });
expect(res.status).toBe(413);
expect(res.body.error).toMatch(/too large/i);
});
it('connection owner → 200 and a session is registered for the task', async () => {
const h = mkSub();
const app = buildApp({ sub: h.sub });
+7 -1
View File
@@ -1,7 +1,7 @@
import type { IncomingMessage, Server as HttpServer } from 'node:http';
import type { Socket } from 'node:net';
import { WebSocketServer, type WebSocket } from 'ws';
import { Router, type Request, type Response } from 'express';
import { Router, json, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import type { SessionRegistry } from '../ssh/console-registry.js';
import type { ConsoleSession } from '../ssh/console-session.js';
@@ -391,6 +391,12 @@ export function createConsoleSessionRouter(deps: {
const r = Router();
r.post(
'/local/tasks/:taskId/console/session',
// Body parser scoped to THIS route only. The router is mounted on the
// whole /api prefix; a router-level (or mount-level) express.json()
// would intercept every /api request with the default 100kb limit and
// 413 large-but-legitimate bodies (e.g. task attachments) before their
// own parsers run. The body here is just connection_id/cols/rows.
json({ limit: '4kb' }),
deps.requireAuth,
async (req: Request, res: Response) => {
const taskId = req.params.taskId!;
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import type { Response } from 'express';
import { setUntrustedFileResponseHeaders, ensurePathWithin } from './local-api-helpers.js';
function fakeRes(): { res: Response; headers: Record<string, string> } {
const headers: Record<string, string> = {};
const res = {
setHeader(name: string, value: string) {
headers[name] = value;
},
} as unknown as Response;
return { res, headers };
}
describe('setUntrustedFileResponseHeaders', () => {
it('disables MIME sniffing and sandboxes the document', () => {
const { res, headers } = fakeRes();
setUntrustedFileResponseHeaders(res);
// nosniff: a text/* file cannot be re-interpreted as executable HTML
expect(headers['X-Content-Type-Options']).toBe('nosniff');
// CSP sandbox (no allow-scripts token) => scripts disabled + opaque origin,
// so HTML/SVG written into a workspace cannot run on our origin or ride the
// viewer's session (stored XSS, incl. the unauthenticated share endpoint).
expect(headers['Content-Security-Policy']).toBe('sandbox');
expect(headers['Content-Security-Policy']).not.toContain('allow-scripts');
});
});
describe('ensurePathWithin (regression: no escape, no sibling-prefix bypass)', () => {
it('allows a path inside the base', () => {
const base = '/tmp/ws';
expect(ensurePathWithin(base, 'output/a.txt')).toBe('/tmp/ws/output/a.txt');
});
it('rejects traversal out of the base', () => {
expect(() => ensurePathWithin('/tmp/ws', '../etc/passwd')).toThrow();
});
it('rejects a sibling dir sharing the base prefix', () => {
// /tmp/ws-evil must not pass the /tmp/ws containment check
expect(() => ensurePathWithin('/tmp/ws', '../ws-evil/x')).toThrow();
});
});
+17
View File
@@ -15,6 +15,23 @@ export function ensurePathWithin(baseDir: string, requestedPath: string): string
return resolvedPath;
}
/**
* Harden a response that serves user/agent-authored workspace bytes.
*
* Workspace files can hold attacker-influenced HTML/SVG (an agent driven by a
* poisoned web page, or another user's shared task). Without these headers the
* browser renders such a file inline on the app origin and any embedded
* `<script>` runs with the viewer's session — stored XSS, and via the
* unauthenticated share endpoint it crosses to other users. `Content-Security-Policy:
* sandbox` forces an opaque origin and disables script execution while still
* letting the in-app preview render the markup; `nosniff` stops the browser from
* sniffing a text/* file back into executable HTML.
*/
export function setUntrustedFileResponseHeaders(res: Response): void {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', 'sandbox');
}
/** True when the error came from ensurePathWithin's traversal guard. */
export function isPathEscapeError(err: unknown): boolean {
return err instanceof Error && err.message === 'Path escapes workspace';
+3 -1
View File
@@ -4,7 +4,7 @@ import { join, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
export function mountLocalFilesApi(app: Application, repo: Repository): void {
@@ -80,6 +80,7 @@ export function mountLocalFilesApi(app: Application, repo: Repository): void {
res.status(400).json({ error: 'path must point to a file' });
return;
}
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
@@ -124,6 +125,7 @@ export function mountLocalFilesApi(app: Application, repo: Repository): void {
res.status(400).json({ error: 'path must point to a file' });
return;
}
setUntrustedFileResponseHeaders(res);
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js';
describe('throttleScopeForIp', () => {
it('keys IPv4 on the full address', () => {
expect(throttleScopeForIp('203.0.113.7')).toBe('203.0.113.7');
});
it('passes through unknown', () => {
expect(throttleScopeForIp('unknown')).toBe('unknown');
});
it('collapses a full IPv6 address to its /64 prefix', () => {
expect(throttleScopeForIp('2001:db8:1:2:aaaa:bbbb:cccc:dddd')).toBe('2001:db8:1:2::/64');
});
it('maps different hosts in the same /64 to the same key', () => {
const a = throttleScopeForIp('2001:db8:abcd:1234::1');
const b = throttleScopeForIp('2001:db8:abcd:1234:ffff:ffff:ffff:ffff');
expect(a).toBe(b);
expect(a).toBe('2001:db8:abcd:1234::/64');
});
it('expands a :: run before taking the prefix', () => {
expect(throttleScopeForIp('2001:db8::1')).toBe('2001:db8:0:0::/64');
});
it('strips an IPv6 zone id', () => {
expect(throttleScopeForIp('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
});
it('keys an IPv4-mapped IPv6 on the whole address (single host)', () => {
expect(throttleScopeForIp('::ffff:203.0.113.7')).toBe('::ffff:203.0.113.7');
});
});
function fixedClock(start = 1_000_000) {
let t = start;
return { now: () => t, advance: (ms: number) => { t += ms; } };
}
describe('LoginRateLimiter', () => {
it('locks a key after maxAttempts failures within the window', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 3, windowMs: 1000, lockoutMs: 5000, now: clock.now });
expect(rl.isLocked('ip:1')).toBe(false);
rl.recordFailure('ip:1');
rl.recordFailure('ip:1');
expect(rl.isLocked('ip:1')).toBe(false); // 2 < 3
rl.recordFailure('ip:1');
expect(rl.isLocked('ip:1')).toBe(true); // 3 >= 3 -> locked
});
it('clears the lockout after lockoutMs elapses', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
rl.recordFailure('ip:1');
rl.recordFailure('ip:1');
expect(rl.isLocked('ip:1')).toBe(true);
clock.advance(5001);
expect(rl.isLocked('ip:1')).toBe(false);
});
it('keeps the lockout when the window expires while still locked', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
rl.recordFailure('ip:1');
rl.recordFailure('ip:1'); // locked until t+5000
expect(rl.isLocked('ip:1')).toBe(true);
clock.advance(1500); // window expired, lockout still active
rl.recordFailure('ip:1'); // must not replace the entry and clear blockedUntil
expect(rl.isLocked('ip:1')).toBe(true);
});
it('resets the window when failures are spread beyond windowMs', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
rl.recordFailure('ip:1');
clock.advance(1001); // window expired -> next failure starts fresh
rl.recordFailure('ip:1');
expect(rl.isLocked('ip:1')).toBe(false);
});
it('reset() clears a key (called on successful login)', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
rl.recordFailure('ip:1');
rl.recordFailure('ip:1');
expect(rl.isLocked('ip:1')).toBe(true);
rl.reset('ip:1');
expect(rl.isLocked('ip:1')).toBe(false);
});
it('isLocked is true if ANY supplied key is locked', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 1, windowMs: 1000, lockoutMs: 5000, now: clock.now });
rl.recordFailure('ip:8');
expect(rl.isLocked('ip:9', 'ip:8')).toBe(true);
expect(rl.isLocked('ip:9')).toBe(false);
});
it('bounds memory: never exceeds maxKeys even under arbitrary distinct keys', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 5, windowMs: 60_000, lockoutMs: 60_000, maxKeys: 50, now: clock.now });
for (let i = 0; i < 500; i++) rl.recordFailure(`ip:${i}`);
// @ts-expect-error — reach into the private map for the invariant check
expect(rl.byKey.size).toBeLessThanOrEqual(50);
});
it('eviction prefers expired entries, then oldest', () => {
const clock = fixedClock();
const rl = new LoginRateLimiter({ maxAttempts: 5, windowMs: 1000, lockoutMs: 1000, maxKeys: 3, now: clock.now });
rl.recordFailure('ip:old'); // will expire
clock.advance(2000);
rl.recordFailure('ip:a');
rl.recordFailure('ip:b');
rl.recordFailure('ip:c'); // size 3 (old expired but still present until swept)
// @ts-expect-error private
expect(rl.byKey.has('ip:old')).toBe(false); // swept on the bound check
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* In-memory login throttle for the local-auth endpoints.
*
* Local login (`POST /auth/local`) had no rate limiting, so an attacker could
* script unlimited online password guesses. This adds a per-key (IP and
* account) failure counter with temporary lockout. scrypt verification already
* makes each guess costly; this caps the attempt rate on top.
*
* Scope: process-local. The orchestrator's default deployment is single-process,
* so a Map is sufficient and avoids a new dependency. A multi-process / HA
* deployment behind a load balancer would need a shared store (Redis) — noted
* for operators; not implemented here.
*/
export interface LoginRateLimitOptions {
/** Failures allowed within the window before lockout kicks in. */
maxAttempts: number;
/** Sliding window for counting failures (ms). */
windowMs: number;
/** How long a key stays locked out after exceeding maxAttempts (ms). */
lockoutMs: number;
/**
* Hard cap on tracked keys (memory-DoS bound). When exceeded, expired entries
* are swept; if still over, the oldest entry is evicted. Default 10000.
*/
maxKeys?: number;
/** Injectable clock for tests. Defaults to Date.now. */
now?: () => number;
}
interface Attempt {
count: number;
firstAt: number;
blockedUntil: number;
}
export class LoginRateLimiter {
private readonly byKey = new Map<string, Attempt>();
private readonly opts: Required<LoginRateLimitOptions>;
constructor(opts: LoginRateLimitOptions) {
this.opts = { now: () => Date.now(), maxKeys: 10_000, ...opts };
}
private now(): number {
return this.opts.now();
}
/** Remaining lockout in ms for this key, or 0 if it may proceed. */
retryAfterMs(key: string): number {
const a = this.byKey.get(key);
if (!a) return 0;
const now = this.now();
if (a.blockedUntil > now) return a.blockedUntil - now;
return 0;
}
/** True when any of the supplied keys is currently locked out. */
isLocked(...keys: string[]): boolean {
return keys.some((k) => this.retryAfterMs(k) > 0);
}
/** Record a failed attempt for a key; trips the lockout past the threshold. */
recordFailure(key: string): void {
const now = this.now();
let a = this.byKey.get(key);
// Never replace an entry whose lockout is still active: with
// lockoutMs > windowMs, a failure after the window expired would otherwise
// reset blockedUntil to 0 and silently clear the lockout. (Callers gate on
// isLocked() first, but the class must stay safe without that gate.)
if (!a || (now - a.firstAt > this.opts.windowMs && a.blockedUntil <= now)) {
// New/expired key: enforce the memory bound before inserting.
if (!a && this.byKey.size >= this.opts.maxKeys) this.evictToBound();
a = { count: 0, firstAt: now, blockedUntil: 0 };
this.byKey.set(key, a);
}
a.count += 1;
if (a.count >= this.opts.maxAttempts) {
a.blockedUntil = now + this.opts.lockoutMs;
}
}
/** Sweep expired entries; if still at/over the cap, evict the oldest. */
private evictToBound(): void {
this.sweep();
while (this.byKey.size >= this.opts.maxKeys) {
// Map preserves insertion order, so the first key is the oldest.
const oldest = this.byKey.keys().next().value;
if (oldest === undefined) break;
this.byKey.delete(oldest);
}
}
/** Clear a key's failure record (call on a successful login). */
reset(key: string): void {
this.byKey.delete(key);
}
/** Drop stale entries; call periodically to bound memory. */
sweep(): void {
const now = this.now();
for (const [k, a] of this.byKey) {
if (a.blockedUntil <= now && now - a.firstAt > this.opts.windowMs) {
this.byKey.delete(k);
}
}
}
}
/**
* Throttle scope for a client IP.
*
* IPv4 (and 'unknown' / IPv4-mapped IPv6) key on the full address — one host.
* Raw IPv6 keys on the /64 prefix: a residential/cloud client is typically
* allocated a whole /64 (2^64 addresses) and can rotate the source address per
* request, which would defeat a full-address throttle. Collapsing to /64 keeps
* the limit meaningful against that rotation while not over-blocking unrelated
* networks (a /64 is a single subscriber's allocation).
*/
export function throttleScopeForIp(ip: string): string {
const bare = (ip || '').split('%')[0].toLowerCase(); // strip zone id
// IPv4, 'unknown', or IPv4-mapped IPv6 (contains a dotted quad): key whole.
if (!bare.includes(':') || bare.includes('.')) return bare;
// Expand a '::' run so we can read the leading 4 hextets (the /64 network).
let groups: string[];
if (bare.includes('::')) {
const [l, r] = bare.split('::');
const left = l ? l.split(':') : [];
const right = r ? r.split(':') : [];
const fill = Math.max(0, 8 - left.length - right.length);
groups = [...left, ...Array(fill).fill('0'), ...right];
} else {
groups = bare.split(':');
}
if (groups.length < 4) return bare; // malformed — key the whole address
return groups.slice(0, 4).map((g) => g || '0').join(':') + '::/64';
}
+48 -3
View File
@@ -168,10 +168,25 @@ export function createCoreServer(opts: CoreServerOptions): {
* read current state for the admin status endpoint.
*/
gatewayMount: GatewayMountHandle | null;
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
authActive: boolean;
} {
const { repo, worktreeDir } = opts;
const app = express();
// Don't advertise the framework/version (minor info-leak, free to drop).
app.disable('x-powered-by');
// Baseline hardening header on every response. `nosniff` stops the browser
// from MIME-sniffing a response into something executable. File-serving
// endpoints add `Content-Security-Policy: sandbox` on top
// (see setUntrustedFileResponseHeaders) to neutralize stored XSS from
// agent/user-authored workspace files.
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
// リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
if (opts.authConfig?.secureCookie) {
app.set('trust proxy', 1);
@@ -816,9 +831,13 @@ export function createCoreServer(opts: CoreServerOptions): {
// POST /api/local/tasks/:taskId/console/session. Reuses the same
// SshSubsystem + preflight the console tools use; the access gate
// runs inside openConsoleSession against task.pieceName.
// NOTE: no express.json() here — the router is mounted on the whole
// /api prefix, so a mount-level parser (default limit 100kb) would
// run for EVERY /api request and 413 large bodies before the
// route-specific parsers (e.g. the task-attachment limit) ever ran.
// The session route carries its own scoped json() parser.
app.use(
'/api',
express.json(),
createConsoleSessionRouter({
sub: sshSubsystem,
preflight: sshPreflight,
@@ -1157,7 +1176,7 @@ export function createCoreServer(opts: CoreServerOptions): {
return isOwner || user.role === 'admin';
};
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount };
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive };
}
export function finalizeServer(app: express.Application): express.Application {
@@ -1166,6 +1185,15 @@ export function finalizeServer(app: express.Application): express.Application {
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
// body-parser signals an exceeded json({ limit }) with type
// 'entity.too.large'. Surface it as the 413 it is — collapsing it into a
// generic 500 hides the actual problem ("attachment over the configured
// limit") from API clients and the UI.
if ((err as { type?: string }).type === 'entity.too.large') {
logger.warn(`Request body too large: ${err.message}`);
res.status(413).json({ error: 'Request body too large' });
return;
}
logger.error(`Unhandled error: ${err.message}`);
res.status(500).json({ error: 'Internal server error' });
});
@@ -1202,6 +1230,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
backendStatusRegistry,
workerMetrics,
gatewayMount,
authActive,
// Forward the actual port to createCoreServer so the admin gateway
// status endpoint reports the real bind port (not the PORT env
// guess). See `listenPort` doc on CoreServerOptions.
@@ -1218,9 +1247,25 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
opts.workerManager.setWorkerMetrics(workerMetrics);
}
const finalApp = finalizeServer(app);
const host = process.env['HOST'] ?? '0.0.0.0';
// Safe-by-default bind: loopback unless HOST is set explicitly. The agent API
// includes the Bash tool, so an unauthenticated instance reachable on the LAN
// is effectively unauthenticated RCE. Bare-metal / systemd installs inherit
// this loopback default; the Docker image sets HOST=0.0.0.0 (it must bind all
// interfaces inside the container) and is protected by the host-side
// 127.0.0.1:9876 port mapping instead.
const host = process.env['HOST'] ?? '127.0.0.1';
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
const server = finalApp.listen(port, host, () => {
logger.info(`Core server listening on ${host}:${port}`);
if (!isLoopbackBind && !authActive) {
logger.warn(
`[security] Listening on ${host} with authentication DISABLED. The agent API ` +
`(including the Bash tool) is reachable by anyone who can reach this host — ` +
`this is effectively unauthenticated remote code execution. Enable auth in ` +
`config.yaml (auth.local or an OAuth provider) before exposing a non-loopback ` +
`interface, or unset HOST to bind 127.0.0.1.`,
);
}
});
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
+3 -1
View File
@@ -4,7 +4,7 @@ import { join, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership, ensurePathWithin, isPathEscapeError } from './local-api-helpers.js';
import { checkTaskOwnership, ensurePathWithin, isPathEscapeError, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
function sanitizeTaskForPublic(task: Record<string, unknown>): Record<string, unknown> {
const { ownerId, workspacePath, body, ...safe } = task;
@@ -80,6 +80,7 @@ export function mountShareApi(app: express.Application, repo: Repository): void
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
@@ -105,6 +106,7 @@ export function mountShareApi(app: express.Application, repo: Repository): void
const stat = statSync(filePath);
if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; }
setUntrustedFileResponseHeaders(res);
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
+2 -1
View File
@@ -4,7 +4,7 @@ import { resolve, sep } from 'path';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { canViewTask } from './local-api-helpers.js';
import { canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
@@ -93,6 +93,7 @@ export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
res.json({ files: dirFiles }); return;
}
setUntrustedFileResponseHeaders(res);
res.sendFile(resolved);
} catch (err) {
logger.error(`Subtask files API error: ${err}`);
+12
View File
@@ -379,6 +379,18 @@ describe('User Folder API', () => {
expect(res.text).toBe('console.log("hello")');
});
it('sets untrusted-file response headers on text content', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'headers.js'), 'console.log("h")');
const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=headers.js');
expect(res.status).toBe(200);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['content-security-policy']).toBe('sandbox');
expect(res.headers['content-type']).toContain('text/plain');
});
it('returns 400 for path traversal attempt', async () => {
const res = await request(app).get(
'/api/users/me/folder/file?subdir=browser-macros&path=../../etc/passwd',
+3
View File
@@ -13,6 +13,7 @@ import {
deleteUserAgentsMd,
} from '../user-folder/paths.js';
import { logger } from '../logger.js';
import { setUntrustedFileResponseHeaders } from './local-api-helpers.js';
import { compileScript } from '../user-folder/script-compiler.js';
import { parseScript, serializeScript } from '../user-folder/frontmatter.js';
import { runUserScript } from '../user-folder/script-runner.js';
@@ -190,6 +191,7 @@ export function createUserFolderApi(deps: Deps): Router {
res.status(404).json({ error: 'Asset not found' });
return;
}
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', asset.contentType);
res.sendFile(asset.path);
});
@@ -323,6 +325,7 @@ export function createUserFolderApi(deps: Deps): Router {
try {
const content = readFileSync(fullPath, 'utf-8');
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(content);
} catch (err) {