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
This commit is contained in:
gsknnft
2026-04-03 18:02:06 -04:00
committed by GitHub
parent 083c146aac
commit 051d0ce469
14 changed files with 572 additions and 30 deletions
+3
View File
@@ -227,6 +227,8 @@ Common environment variables:
- `HOST` and `PORT` control the Studio server bind address and port. - `HOST` and `PORT` control the Studio server bind address and port.
- `STUDIO_ACCESS_TOKEN` protects Studio when binding to a public host. - `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. - `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. - `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. - `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. - `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`. - `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. - `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`. - 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. Marketplace skill installs now use a gateway-native workspace flow and do not require enabling SSH on the user machine.
+8
View File
@@ -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. - 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. - 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 ## Scope
@@ -35,3 +36,10 @@ Please report issues related to:
- Remote code execution or privilege escalation paths. - Remote code execution or privilege escalation paths.
- Unsafe filesystem, proxy, or network behavior. - Unsafe filesystem, proxy, or network behavior.
- Dependency vulnerabilities that materially affect this project. - 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.
+51
View File
@@ -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
+58 -1
View File
@@ -1,5 +1,62 @@
import type { NextConfig } from "next"; 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; export default nextConfig;
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "claw3d", "name": "claw3d",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -23,7 +23,6 @@
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@vercel/otel": "^2.1.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+76 -10
View File
@@ -1,3 +1,6 @@
const crypto = require("node:crypto");
const parseCookies = (header) => { const parseCookies = (header) => {
const raw = typeof header === "string" ? header : ""; const raw = typeof header === "string" ? header : "";
if (!raw.trim()) return {}; if (!raw.trim()) return {};
@@ -13,34 +16,98 @@ const parseCookies = (header) => {
return out; 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) { function createAccessGate(options) {
const token = String(options?.token ?? "").trim(); const token = String(options?.token ?? "").trim();
const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access"; const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access";
const enabled = Boolean(token); const enabled = Boolean(token);
const rateLimiter = createRateLimiter(10, 60_000);
const isAuthorized = (req) => { const getAuthState = (req) => {
if (!enabled) return true; if (!enabled) return { authorized: true, limited: false };
const ip = req.socket?.remoteAddress || "unknown";
const cookieHeader = req.headers?.cookie; const cookieHeader = req.headers?.cookie;
const cookies = parseCookies(cookieHeader); 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) => { const handleHttp = (req, res) => {
if (!enabled) return false; 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/")) { if (String(req.url || "/").startsWith("/api/")) {
res.statusCode = 401; res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Type", "application/json");
res.end( res.end(
JSON.stringify({ 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 { } else {
res.statusCode = 401; res.statusCode = statusCode;
res.setHeader("Content-Type", "text/plain"); 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; return true;
} }
@@ -49,11 +116,10 @@ function createAccessGate(options) {
const allowUpgrade = (req) => { const allowUpgrade = (req) => {
if (!enabled) return true; if (!enabled) return true;
return isAuthorized(req); return getAuthState(req).authorized;
}; };
return { enabled, handleHttp, allowUpgrade }; return { enabled, handleHttp, allowUpgrade };
} }
module.exports = { createAccessGate }; module.exports = { createAccessGate };
+66 -2
View File
@@ -1,5 +1,12 @@
const { Buffer } = require("node:buffer");
const { WebSocket, WebSocketServer } = require("ws"); 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) => { const buildErrorResponse = (id, code, message) => {
return { return {
type: "res", 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 resolvePathname = (url) => {
const raw = typeof url === "string" ? url : ""; const raw = typeof url === "string" ? url : "";
const idx = raw.indexOf("?"); const idx = raw.indexOf("?");
@@ -106,10 +146,11 @@ function createGatewayProxy(options) {
let pendingConnectFrame = null; let pendingConnectFrame = null;
let pendingUpstreamSetupError = null; let pendingUpstreamSetupError = null;
let closed = false; let closed = false;
const frameRateLimiter = createFrameRateLimiter();
const closeBoth = (code, reason) => { const closeBoth = (code, reason) => {
if (closed) return; if (closed) return;
closed = true; closed = true;
frameRateLimiter.destroy();
try { try {
browserWs.close(code, reason); browserWs.close(code, reason);
} catch {} } catch {}
@@ -191,6 +232,14 @@ function createGatewayProxy(options) {
return; return;
} }
if (!isUpstreamAllowed(upstreamUrl)) {
pendingUpstreamSetupError = {
code: "studio.gateway_url_blocked",
message: "Upstream gateway URL is not in the allowed hosts list.",
};
return;
}
let upstreamOrigin = ""; let upstreamOrigin = "";
try { try {
upstreamOrigin = resolveOriginForUpstream(upstreamUrl); upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
@@ -250,7 +299,22 @@ function createGatewayProxy(options) {
void startUpstream(); void startUpstream();
browserWs.on("message", async (raw) => { 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)) { if (!parsed || !isObject(parsed)) {
closeBoth(1003, "invalid json"); closeBoth(1003, "invalid json");
return; return;
+28 -4
View File
@@ -62,6 +62,12 @@ const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime
return { resolved, 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 validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
const { trimmed, mime } = validateRawMediaPath(raw); const { trimmed, mime } = validateRawMediaPath(raw);
@@ -83,15 +89,32 @@ const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: strin
return { remotePath: trimmed, mime }; return { remotePath: trimmed, mime };
}; };
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => { const readLocalMedia = async (
const stat = await fs.stat(resolvedPath); 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()) { if (!stat.isFile()) {
throw new Error("path is not a file"); throw new Error("path is not a file");
} }
if (stat.size > MAX_MEDIA_BYTES) { if (stat.size > MAX_MEDIA_BYTES) {
throw new Error(`media file too large (${stat.size} 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 }; return { bytes: buf, size: stat.size };
}; };
@@ -166,7 +189,8 @@ export async function GET(request: Request) {
if (!sshTarget) { if (!sshTarget) {
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath); 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 }); const body = new Blob([Uint8Array.from(bytes)], { type: mime });
return new Response(body, { return new Response(body, {
headers: { headers: {
+55 -5
View File
@@ -9,6 +9,27 @@ type CustomRuntimeRequestBody = {
body?: unknown; 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 normalizeRuntimeUrl = (value: string): string => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
@@ -25,7 +46,11 @@ const normalizeRuntimeUrl = (value: string): string => {
} }
parsed.username = ""; parsed.username = "";
parsed.password = ""; 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 => { const normalizePathname = (value: unknown): string => {
@@ -44,8 +69,18 @@ const normalizeMethod = (value: unknown): "GET" | "POST" => {
}; };
export async function POST(request: Request) { 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 { try {
const payload = (await request.json()) as CustomRuntimeRequestBody;
const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? ""); const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? "");
const pathname = normalizePathname(payload.pathname); const pathname = normalizePathname(payload.pathname);
const method = normalizeMethod(payload.method); const method = normalizeMethod(payload.method);
@@ -67,8 +102,23 @@ export async function POST(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Custom runtime proxy failed.";
error instanceof Error ? error.message : "Custom runtime proxy failed."; const status =
return NextResponse.json({ error: message }, { status: 400 }); 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 }
);
} }
} }
+4 -4
View File
@@ -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 = () => { export const register = () => {};
registerOTel({ serviceName: "claw3d" });
};
+104
View File
@@ -45,4 +45,108 @@ describe("createAccessGate", () => {
gate.allowUpgrade({ headers: { cookie: "studio_access=abc" } }) gate.allowUpgrade({ headers: { cookie: "studio_access=abc" } })
).toBe(true); ).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");
});
}); });
+86
View File
@@ -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.",
});
});
});
+30
View File
@@ -123,5 +123,35 @@ describe("/api/gateway/media route", () => {
expect(typeof options.maxBuffer).toBe("number"); expect(typeof options.maxBuffer).toBe("number");
expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length); 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 });
});
}); });
+2 -2
View File
@@ -69,7 +69,7 @@ describe("studio settings route", () => {
process.env.OPENCLAW_STATE_DIR = tempDir; process.env.OPENCLAW_STATE_DIR = tempDir;
const response = await PUT({ const response = await PUT({
json: async () => "nope", text: async () => JSON.stringify("nope"),
} as unknown as Request); } as unknown as Request);
const body = (await response.json()) as { error?: string }; const body = (await response.json()) as { error?: string };
@@ -92,7 +92,7 @@ describe("studio settings route", () => {
}; };
const putResponse = await PUT({ const putResponse = await PUT({
json: async () => patch, text: async () => JSON.stringify(patch),
} as unknown as Request); } as unknown as Request);
expect(putResponse.status).toBe(200); expect(putResponse.status).toBe(200);