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