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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user