From 051d0ce46977869d25e01d25d5f294edb24f48dc Mon Sep 17 00:00:00 2001 From: gsknnft <123185582+gsknnft@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:02:06 -0400 Subject: [PATCH] security: harden gateway proxy, custom runtime proxy, and media routes (#95) * security hardening pass 1 - otel removed * hardening pass #2 * feat security hardening pass * chore: trim unrelated docs from security hardening pr * fix: address security hardening review findings * address findings --- README.md | 3 + SECURITY.md | 8 ++ SECURITY_HARDENING.md | 51 ++++++++++++ next.config.ts | 59 +++++++++++++- package.json | 3 +- server/access-gate.js | 86 +++++++++++++++++--- server/gateway-proxy.js | 68 +++++++++++++++- src/app/api/gateway/media/route.ts | 32 +++++++- src/app/api/runtime/custom/route.ts | 60 ++++++++++++-- src/instrumentation.ts | 8 +- tests/unit/accessGate.test.ts | 104 +++++++++++++++++++++++++ tests/unit/customRuntimeRoute.test.ts | 86 ++++++++++++++++++++ tests/unit/gatewayMediaRoute.test.ts | 30 +++++++ tests/unit/studioSettingsRoute.test.ts | 4 +- 14 files changed, 572 insertions(+), 30 deletions(-) create mode 100644 SECURITY_HARDENING.md create mode 100644 tests/unit/customRuntimeRoute.test.ts diff --git a/README.md b/README.md index 393b026..b4e611a 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,8 @@ Common environment variables: - `HOST` and `PORT` control the Studio server bind address and port. - `STUDIO_ACCESS_TOKEN` protects Studio when binding to a public host. +- `UPSTREAM_ALLOWLIST` restricts which upstream gateway hosts Studio may proxy to. Set this in production. +- `CUSTOM_RUNTIME_ALLOWLIST` restricts which hosts `/api/runtime/custom` may fetch. If unset, it falls back to `UPSTREAM_ALLOWLIST`. - `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty. **Note:** this is a build-time variable — changes require `npm run build` to take effect. - `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild. These are also used as a fallback when `openclaw.json` is not present. - `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` override the default OpenClaw paths. @@ -277,6 +279,7 @@ If the UI loads but Connect fails, the problem is usually on the Studio -> Gatew - `EPROTO` or `wrong version number` usually means `wss://` was used against a non-TLS endpoint. - `INVALID_REQUEST` errors mentioning `minProtocol` or `maxProtocol` usually mean the gateway is too old for Claw3D protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway`. - `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled and the request is missing the expected `studio_access` cookie. +- If `/api/runtime/custom` returns a blocked-host error in production, set `CUSTOM_RUNTIME_ALLOWLIST` or include the runtime host in `UPSTREAM_ALLOWLIST`. - Helpful proxy error codes include `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, and `studio.upstream_closed`. Marketplace skill installs now use a gateway-native workspace flow and do not require enabling SSH on the user machine. diff --git a/SECURITY.md b/SECURITY.md index fe80e63..2f5c805 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,6 +25,7 @@ We aim to acknowledge reports promptly, investigate them, and coordinate a fix a - Studio gateway settings are stored on disk in plaintext under the local OpenClaw state directory. - The current UI loads the configured upstream gateway URL/token into browser memory at runtime, even though those values are not stored in browser persistent storage. +- There is currently no built-in cookie issuance/login flow for `STUDIO_ACCESS_TOKEN`; deployments that enable the access gate must provision the `studio_access` cookie outside the app. ## Scope @@ -35,3 +36,10 @@ Please report issues related to: - Remote code execution or privilege escalation paths. - Unsafe filesystem, proxy, or network behavior. - Dependency vulnerabilities that materially affect this project. + +## Deployment Notes + +- In production, set `UPSTREAM_ALLOWLIST` for the Studio gateway proxy. +- In production, set `CUSTOM_RUNTIME_ALLOWLIST` if you use `/api/runtime/custom`. If unset, it falls back to `UPSTREAM_ALLOWLIST`. +- Empty allowlists are intended for local development only. +- If you enable `STUDIO_ACCESS_TOKEN`, you must also provision the `studio_access` cookie through your deployment/auth layer. diff --git a/SECURITY_HARDENING.md b/SECURITY_HARDENING.md new file mode 100644 index 0000000..b2a52b0 --- /dev/null +++ b/SECURITY_HARDENING.md @@ -0,0 +1,51 @@ +# Security Hardening + +Changes applied to the upstream Claw3D codebase for production use. + +## Critical Fixes + +### 1. Telemetry Removed +- `@vercel/otel` dependency removed from package.json +- `src/instrumentation.ts` replaced with no-op +- No data is sent to Vercel or any external telemetry service + +### 2. Constant-Time Token Comparison +- `server/access-gate.js` now uses `crypto.timingSafeEqual()` for + token validation, preventing timing attacks + +### 3. Auth Rate Limiting +- In-memory rate limiter added to access gate for failed auth attempts + only (10 failures per IP per 60 seconds) +- Prevents brute-force token guessing + +### 4. WebSocket Frame Validation +- Maximum frame size: 256 KB (prevents resource exhaustion) +- Per-connection rate limit: 30 frames/second +- Connections closed on violation + +### 5. Upstream URL Allowlist +- `UPSTREAM_ALLOWLIST` env var restricts which gateway hosts the + WebSocket proxy can connect to +- Prevents DNS hijacking or SSRF through the proxy +- Required in production; empty allowlist is permitted in dev only + +### 6. Custom Runtime Proxy Allowlist +- `/api/runtime/custom` now enforces `CUSTOM_RUNTIME_ALLOWLIST` +- Falls back to `UPSTREAM_ALLOWLIST` if no custom-specific allowlist is set +- Required in production; empty allowlist is permitted in dev only + +### 7. Security Headers +- Baseline response headers now set from `next.config.ts` +- Includes CSP, `X-Content-Type-Options`, `Referrer-Policy`, + `Permissions-Policy`, and cross-origin isolation headers + +### 8. Media Route Symlink Rejection +- `/api/gateway/media` now rejects symlinked local files +- Realpath is verified inside the allowed root before reading bytes + +## Remaining Items (Phase 2) + +- Encrypt gateway tokens at rest +- Add Zod schema validation for all API inputs +- Implement secure cookie flags (HttpOnly, Secure, SameSite) +- Sanitize error messages before sending to clients diff --git a/next.config.ts b/next.config.ts index cb651cd..c57dbda 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,62 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + "img-src 'self' data: blob: http: https:", + "font-src 'self' data: https:", + "style-src 'self' 'unsafe-inline' https:", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:", + "connect-src 'self' ws: wss: http: https:", + "media-src 'self' blob: data: http: https:", + "worker-src 'self' blob:", + "object-src 'none'", + "upgrade-insecure-requests", + ].join("; "), + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(self), geolocation=(), browsing-topics=()", + }, + { + key: "Cross-Origin-Resource-Policy", + value: "same-origin", + }, +]; + +if (process.env.NODE_ENV === "production") { + securityHeaders.push({ + key: "Strict-Transport-Security", + value: "max-age=31536000; includeSubDomains", + }); +} + +const nextConfig: NextConfig = { + async headers() { + return [ + { + source: "/:path*", + headers: securityHeaders, + }, + ]; + }, +}; export default nextConfig; diff --git a/package.json b/package.json index 4995bcd..c16d970 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claw3d", - "version": "0.1.4", + "version": "0.1.5", "private": true, "license": "MIT", "scripts": { @@ -23,7 +23,6 @@ "@noble/ed25519": "^3.0.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", - "@vercel/otel": "^2.1.0", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/server/access-gate.js b/server/access-gate.js index cd8e529..1f5e72e 100644 --- a/server/access-gate.js +++ b/server/access-gate.js @@ -1,3 +1,6 @@ +const crypto = require("node:crypto"); + + const parseCookies = (header) => { const raw = typeof header === "string" ? header : ""; if (!raw.trim()) return {}; @@ -13,34 +16,98 @@ const parseCookies = (header) => { return out; }; +/** Constant-time string comparison to prevent timing attacks. */ +const safeCompare = (a, b) => { + if (typeof a !== "string" || typeof b !== "string") return false; + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + if (bufA.length !== bufB.length) { + // Compare against self to burn constant time, then return false + crypto.timingSafeEqual(bufA, bufA); + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +}; + +/** Simple in-memory rate limiter for auth attempts. */ +const createRateLimiter = (maxAttempts = 10, windowMs = 60_000) => { + const attempts = new Map(); + const cleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of attempts) { + if (now - entry.start > windowMs) attempts.delete(key); + } + }, windowMs); + cleanup.unref(); + + return { + isLimited(ip) { + const entry = attempts.get(ip); + if (!entry) return false; + return entry.count >= maxAttempts; + }, + recordFailure(ip) { + const now = Date.now(); + const entry = attempts.get(ip); + if (!entry || now - entry.start > windowMs) { + attempts.set(ip, { count: 1, start: now }); + return; + } + entry.count++; + }, + reset(ip) { + attempts.delete(ip); + }, + }; +}; + function createAccessGate(options) { const token = String(options?.token ?? "").trim(); const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access"; const enabled = Boolean(token); + const rateLimiter = createRateLimiter(10, 60_000); - const isAuthorized = (req) => { - if (!enabled) return true; + const getAuthState = (req) => { + if (!enabled) return { authorized: true, limited: false }; + const ip = req.socket?.remoteAddress || "unknown"; const cookieHeader = req.headers?.cookie; const cookies = parseCookies(cookieHeader); - return cookies[cookieName] === token; + const authorized = safeCompare(cookies[cookieName] || "", token); + if (authorized) { + rateLimiter.reset(ip); + return { authorized: true, limited: false }; + } + if (rateLimiter.isLimited(ip)) { + return { authorized: false, limited: true }; + } + rateLimiter.recordFailure(ip); + return { authorized: false, limited: rateLimiter.isLimited(ip) }; }; const handleHttp = (req, res) => { if (!enabled) return false; - if (!isAuthorized(req)) { + const auth = getAuthState(req); + if (!auth.authorized) { + const statusCode = auth.limited ? 429 : 401; if (String(req.url || "/").startsWith("/api/")) { - res.statusCode = 401; + res.statusCode = statusCode; res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ - error: "Studio access token required. Send the configured Studio access cookie and retry.", + error: auth.limited + ? "Too many failed studio access attempts. Wait a minute and retry." + : "Studio access token required. Send the configured Studio access cookie and retry.", }) ); } else { - res.statusCode = 401; + res.statusCode = statusCode; res.setHeader("Content-Type", "text/plain"); - res.end("Studio access token required. Set the studio_access cookie to access this page."); + res.end( + auth.limited + ? "Too many failed studio access attempts. Wait a minute and retry." + : "Studio access token required. Set the studio_access cookie to access this page." + ); } return true; } @@ -49,11 +116,10 @@ function createAccessGate(options) { const allowUpgrade = (req) => { if (!enabled) return true; - return isAuthorized(req); + return getAuthState(req).authorized; }; return { enabled, handleHttp, allowUpgrade }; } module.exports = { createAccessGate }; - diff --git a/server/gateway-proxy.js b/server/gateway-proxy.js index cefda25..ef5cab5 100644 --- a/server/gateway-proxy.js +++ b/server/gateway-proxy.js @@ -1,5 +1,12 @@ +const { Buffer } = require("node:buffer"); const { WebSocket, WebSocketServer } = require("ws"); +/** Maximum frame payload size (256 KB). */ +const MAX_FRAME_SIZE = 256 * 1024; + +/** Maximum frames per connection per second. */ +const MAX_FRAMES_PER_SECOND = 30; + const buildErrorResponse = (id, code, message) => { return { type: "res", @@ -19,6 +26,39 @@ const safeJsonParse = (raw) => { } }; +/** Per-connection frame rate limiter. */ +const createFrameRateLimiter = (maxPerSecond = MAX_FRAMES_PER_SECOND) => { + let count = 0; + const interval = setInterval(() => { count = 0; }, 1000); + interval.unref(); + return { + check() { return ++count <= maxPerSecond; }, + destroy() { clearInterval(interval); }, + }; +}; + +/** + * Validate upstream URL against an allowlist. + * If UPSTREAM_ALLOWLIST env var is set, only those hosts are permitted. + * Format: comma-separated hostnames, e.g. "gateway.percival-labs.ai,localhost" + */ +const isUpstreamAllowed = (url) => { + const allowlist = (process.env.UPSTREAM_ALLOWLIST || "").trim(); + if (!allowlist) { + return process.env.NODE_ENV !== "production"; + } + try { + const parsed = new URL(url); + const allowed = allowlist + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + return allowed.includes(parsed.hostname.toLowerCase()); + } catch { + return false; + } +}; + const resolvePathname = (url) => { const raw = typeof url === "string" ? url : ""; const idx = raw.indexOf("?"); @@ -106,10 +146,11 @@ function createGatewayProxy(options) { let pendingConnectFrame = null; let pendingUpstreamSetupError = null; let closed = false; - + const frameRateLimiter = createFrameRateLimiter(); const closeBoth = (code, reason) => { if (closed) return; closed = true; + frameRateLimiter.destroy(); try { browserWs.close(code, reason); } catch {} @@ -191,6 +232,14 @@ function createGatewayProxy(options) { return; } + if (!isUpstreamAllowed(upstreamUrl)) { + pendingUpstreamSetupError = { + code: "studio.gateway_url_blocked", + message: "Upstream gateway URL is not in the allowed hosts list.", + }; + return; + } + let upstreamOrigin = ""; try { upstreamOrigin = resolveOriginForUpstream(upstreamUrl); @@ -250,7 +299,22 @@ function createGatewayProxy(options) { void startUpstream(); browserWs.on("message", async (raw) => { - const parsed = safeJsonParse(String(raw ?? "")); + const rawStr = String(raw ?? ""); + const rawByteLength = Buffer.byteLength(rawStr, "utf8"); + + // Frame size limit + if (rawByteLength > MAX_FRAME_SIZE) { + closeBoth(1009, "frame too large"); + return; + } + + // Rate limiting + if (!frameRateLimiter.check()) { + closeBoth(1008, "rate limit exceeded"); + return; + } + + const parsed = safeJsonParse(rawStr); if (!parsed || !isObject(parsed)) { closeBoth(1003, "invalid json"); return; diff --git a/src/app/api/gateway/media/route.ts b/src/app/api/gateway/media/route.ts index 39ee95a..762348e 100644 --- a/src/app/api/gateway/media/route.ts +++ b/src/app/api/gateway/media/route.ts @@ -62,6 +62,12 @@ const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime return { resolved, mime }; }; +const isWithinAllowedRoot = (targetPath: string, allowedRoot: string): boolean => { + const relative = path.relative(allowedRoot, targetPath); + if (!relative) return true; + return !relative.startsWith("..") && !path.isAbsolute(relative); +}; + const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => { const { trimmed, mime } = validateRawMediaPath(raw); @@ -83,15 +89,32 @@ const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: strin return { remotePath: trimmed, mime }; }; -const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => { - const stat = await fs.stat(resolvedPath); +const readLocalMedia = async ( + resolvedPath: string, + allowedRoot: string +): Promise<{ bytes: Buffer; size: number }> => { + const entry = await fs.lstat(resolvedPath); + if (entry.isSymbolicLink()) { + throw new Error("symlinked media paths are not allowed"); + } + + const [realResolvedPath, realAllowedRoot] = await Promise.all([ + fs.realpath(resolvedPath), + fs.realpath(allowedRoot).catch(() => path.resolve(allowedRoot)), + ]); + + if (!isWithinAllowedRoot(realResolvedPath, realAllowedRoot)) { + throw new Error(`Refusing to read media outside ${realAllowedRoot}`); + } + + const stat = await fs.stat(realResolvedPath); if (!stat.isFile()) { throw new Error("path is not a file"); } if (stat.size > MAX_MEDIA_BYTES) { throw new Error(`media file too large (${stat.size} bytes)`); } - const buf = await fs.readFile(resolvedPath); + const buf = await fs.readFile(realResolvedPath); return { bytes: buf, size: stat.size }; }; @@ -166,7 +189,8 @@ export async function GET(request: Request) { if (!sshTarget) { const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath); - const { bytes, size } = await readLocalMedia(resolved); + const allowedRoot = path.join(os.homedir(), ".openclaw"); + const { bytes, size } = await readLocalMedia(resolved, allowedRoot); const body = new Blob([Uint8Array.from(bytes)], { type: mime }); return new Response(body, { headers: { diff --git a/src/app/api/runtime/custom/route.ts b/src/app/api/runtime/custom/route.ts index 1b43c56..9f85d5f 100644 --- a/src/app/api/runtime/custom/route.ts +++ b/src/app/api/runtime/custom/route.ts @@ -9,6 +9,27 @@ type CustomRuntimeRequestBody = { body?: unknown; }; +const isRuntimeUrlAllowed = (runtimeUrl: string): boolean => { + const rawAllowlist = ( + process.env.CUSTOM_RUNTIME_ALLOWLIST || + process.env.UPSTREAM_ALLOWLIST || + "" + ).trim(); + if (!rawAllowlist) { + return process.env.NODE_ENV !== "production"; + } + try { + const parsed = new URL(runtimeUrl); + const allowed = rawAllowlist + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + return allowed.includes(parsed.hostname.toLowerCase()); + } catch { + return false; + } +}; + const normalizeRuntimeUrl = (value: string): string => { const trimmed = value.trim(); if (!trimmed) { @@ -25,7 +46,11 @@ const normalizeRuntimeUrl = (value: string): string => { } parsed.username = ""; parsed.password = ""; - return parsed.toString().replace(/\/$/, ""); + const normalized = parsed.toString().replace(/\/$/, ""); + if (!isRuntimeUrlAllowed(normalized)) { + throw new Error("runtimeUrl is not in the allowed hosts list."); + } + return normalized; }; const normalizePathname = (value: unknown): string => { @@ -44,8 +69,18 @@ const normalizeMethod = (value: unknown): "GET" | "POST" => { }; export async function POST(request: Request) { + let payload; + try { + payload = (await request.json()) as CustomRuntimeRequestBody; + } catch (error) { + console.error("[runtime/custom] Invalid JSON request body.", error); + return NextResponse.json( + { error: "Invalid JSON request body." }, + { status: 400 } + ); + } + try { - const payload = (await request.json()) as CustomRuntimeRequestBody; const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? ""); const pathname = normalizePathname(payload.pathname); const method = normalizeMethod(payload.method); @@ -67,8 +102,23 @@ export async function POST(request: Request) { }, }); } catch (error) { - const message = - error instanceof Error ? error.message : "Custom runtime proxy failed."; - return NextResponse.json({ error: message }, { status: 400 }); + const message = error instanceof Error ? error.message : "Custom runtime proxy failed."; + const status = + message === "runtimeUrl is required." || + message === "pathname is required." || + message === "runtimeUrl must use http, https, ws, or wss." || + message === "runtimeUrl is not in the allowed hosts list." + ? 400 + : 502; + console.error("[runtime/custom] Proxy request failed.", error); + return NextResponse.json( + { + error: + status === 400 + ? message + : "Custom runtime proxy failed.", + }, + { status } + ); } } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 7506472..b2ecfe9 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,5 @@ -import { registerOTel } from "@vercel/otel"; +// Telemetry intentionally disabled in this fork. +// Keep Next's instrumentation hook present, but do not register +// any external telemetry providers. -export const register = () => { - registerOTel({ serviceName: "claw3d" }); -}; +export const register = () => {}; diff --git a/tests/unit/accessGate.test.ts b/tests/unit/accessGate.test.ts index afd8a26..cd0740f 100644 --- a/tests/unit/accessGate.test.ts +++ b/tests/unit/accessGate.test.ts @@ -45,4 +45,108 @@ describe("createAccessGate", () => { gate.allowUpgrade({ headers: { cookie: "studio_access=abc" } }) ).toBe(true); }); + + it("returns 429 after repeated failed attempts", async () => { + const { createAccessGate } = await import("../../server/access-gate"); + const gate = createAccessGate({ token: "abc" }); + + const createResponse = () => { + let statusCode = 0; + let body = ""; + return { + setHeader: () => {}, + end: (value?: string) => { + body = value ?? ""; + }, + get statusCode() { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + get body() { + return body; + }, + }; + }; + + for (let index = 0; index < 9; index++) { + const res = createResponse(); + gate.handleHttp( + { url: "/api/studio", headers: {}, socket: { remoteAddress: "127.0.0.1" } }, + res + ); + expect(res.statusCode).toBe(401); + } + + const limited = createResponse(); + gate.handleHttp( + { url: "/api/studio", headers: {}, socket: { remoteAddress: "127.0.0.1" } }, + limited + ); + + expect(limited.statusCode).toBe(429); + expect(limited.body).toContain("Too many failed studio access attempts"); + }); + + it("recovers immediately when a valid cookie is sent after throttling", async () => { + const { createAccessGate } = await import("../../server/access-gate"); + const gate = createAccessGate({ token: "abc" }); + + const createResponse = () => { + let statusCode = 0; + let body = ""; + return { + setHeader: () => {}, + end: (value?: string) => { + body = value ?? ""; + }, + get statusCode() { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + get body() { + return body; + }, + }; + }; + + for (let index = 0; index < 10; index++) { + const res = createResponse(); + gate.handleHttp( + { url: "/api/studio", headers: {}, socket: { remoteAddress: "127.0.0.1" } }, + res + ); + } + + expect( + gate.allowUpgrade({ + headers: { cookie: "studio_access=abc" }, + socket: { remoteAddress: "127.0.0.1" }, + }) + ).toBe(true); + + const recovered = createResponse(); + gate.handleHttp( + { + url: "/api/studio", + headers: { cookie: "studio_access=abc" }, + socket: { remoteAddress: "127.0.0.1" }, + }, + recovered + ); + + expect(recovered.statusCode).toBe(0); + + const afterReset = createResponse(); + gate.handleHttp( + { url: "/api/studio", headers: {}, socket: { remoteAddress: "127.0.0.1" } }, + afterReset + ); + + expect(afterReset.statusCode).toBe(401); + expect(afterReset.body).toContain("Studio access token required"); + }); }); diff --git a/tests/unit/customRuntimeRoute.test.ts b/tests/unit/customRuntimeRoute.test.ts new file mode 100644 index 0000000..e572c12 --- /dev/null +++ b/tests/unit/customRuntimeRoute.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("/api/runtime/custom route", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + it("blocks custom runtime proxying in production when no allowlist is configured", async () => { + Object.assign(process.env, { NODE_ENV: "production" }); + delete process.env.CUSTOM_RUNTIME_ALLOWLIST; + delete process.env.UPSTREAM_ALLOWLIST; + + const { POST } = await import("@/app/api/runtime/custom/route"); + const response = await POST( + new Request("http://localhost/api/runtime/custom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runtimeUrl: "http://127.0.0.1:7770", + pathname: "/health", + method: "GET", + }), + }) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "runtimeUrl is not in the allowed hosts list.", + }); + }); + + it("allows only listed hosts when a custom runtime allowlist is configured", async () => { + Object.assign(process.env, { + NODE_ENV: "production", + CUSTOM_RUNTIME_ALLOWLIST: "127.0.0.1", + }); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const { POST } = await import("@/app/api/runtime/custom/route"); + const response = await POST( + new Request("http://localhost/api/runtime/custom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runtimeUrl: "http://127.0.0.1:7770", + pathname: "/health", + method: "GET", + }), + }) + ); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledWith( + "http://127.0.0.1:7770/health", + expect.objectContaining({ method: "GET" }) + ); + }); + + it("returns 400 for malformed JSON request bodies", async () => { + Object.assign(process.env, { NODE_ENV: "production" }); + + const { POST } = await import("@/app/api/runtime/custom/route"); + const response = await POST( + new Request("http://localhost/api/runtime/custom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{bad json", + }) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "Invalid JSON request body.", + }); + }); +}); diff --git a/tests/unit/gatewayMediaRoute.test.ts b/tests/unit/gatewayMediaRoute.test.ts index 3e8a894..9c043f8 100644 --- a/tests/unit/gatewayMediaRoute.test.ts +++ b/tests/unit/gatewayMediaRoute.test.ts @@ -123,5 +123,35 @@ describe("/api/gateway/media route", () => { expect(typeof options.maxBuffer).toBe("number"); expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length); }); + + it("rejects symlinked local media paths", async () => { + tempDir = makeTempDir("gateway-media-route-local-symlink"); + const realHome = os.homedir(); + const allowedRoot = path.join(realHome, ".openclaw"); + const imagesDir = path.join(allowedRoot, "images"); + const outsideDir = path.join(tempDir, "outside"); + fs.mkdirSync(imagesDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + + const outsideFile = path.join(outsideDir, "secret.png"); + fs.writeFileSync(outsideFile, "not-allowed", "utf8"); + const symlinkPath = path.join(imagesDir, "linked.png"); + fs.symlinkSync(outsideFile, symlinkPath); + + process.env.OPENCLAW_STATE_DIR = tempDir; + writeStudioSettings(tempDir, "ws://localhost:18789"); + + const response = await GET( + new Request( + `http://localhost/api/gateway/media?path=${encodeURIComponent(symlinkPath)}` + ) + ); + const body = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(body.error).toMatch(/symlink/i); + + fs.rmSync(symlinkPath, { force: true }); + }); }); diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts index 45139ef..92904b0 100644 --- a/tests/unit/studioSettingsRoute.test.ts +++ b/tests/unit/studioSettingsRoute.test.ts @@ -69,7 +69,7 @@ describe("studio settings route", () => { process.env.OPENCLAW_STATE_DIR = tempDir; const response = await PUT({ - json: async () => "nope", + text: async () => JSON.stringify("nope"), } as unknown as Request); const body = (await response.json()) as { error?: string }; @@ -92,7 +92,7 @@ describe("studio settings route", () => { }; const putResponse = await PUT({ - json: async () => patch, + text: async () => JSON.stringify(patch), } as unknown as Request); expect(putResponse.status).toBe(200);