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
+104
View File
@@ -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");
});
});
+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(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;
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);