This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user