mirror of
https://github.com/rohitg00/agentmemory.git
synced 2026-09-14 20:16:33 +08:00
1a706ca2d6
* fix(integrations): warn when bearer auth crosses plaintext HTTP to non-loopback (#275) Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * test(integrations/315): edge cases for IPv6 loopback + LAN IPs + lookalike hostnames + docs @mvanhorn's PR #315 lands the right plaintext-bearer-auth warn-once pattern across three runtimes (hermes/python, openclaw/mjs, pi/typescript). Reviewed cleanly. Adding test cases + docs before merge so the contract is locked: Test additions (test/integration-plaintext-http.test.ts pi suite, +4 cases, 13 total in suite): - IPv6 loopback (http://[::1]:3111) — URL parser strips brackets, hostname comes back as `::1`, the LOOPBACK_HOSTS set already covers this. Test pins the parsing semantic so a future refactor can't silently break it. - Private LAN IPs (RFC1918 ranges 192.168.x.y, 10.x.x.x) — NOT loopback, guard MUST warn. RFC1918 was a deliberate design choice (loopback set stays minimal: localhost / 127.0.0.1 / ::1). LAN deployment behind a tunnel still leaks the bearer over the wire. - No-secret short-circuit (guard with secret="" or undefined) — guard never fires when a bearer wouldn't actually be sent. Mirrors the usesPlaintextBearerAuth() early-return. - Lookalike hostname (http://localhost.evil.com:3111) — `parsed.hostname` returns the full `localhost.evil.com` string, which is NOT in LOOPBACK_HOSTS. Guard warns. This pins the exact-match (vs prefix) loopback semantic. Docs additions: - integrations/pi/README.md and integrations/hermes/README.md now document AGENTMEMORY_REQUIRE_HTTPS in the env-vars table — same row shape as AGENTMEMORY_SECRET, names the env value (=1), describes the warn-vs-throw semantic, and lists the loopback hostnames the guard recognises. No code changes to the guard itself — it's already correct. Pre-merge hardening only. 899 / 899 tests pass on rebased branch. Build clean. --------- Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Rohit Ghumare <ghumare64@gmail.com>
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import openclawPlugin from "../integrations/openclaw/plugin.mjs";
|
|
import { createPlaintextBearerAuthGuard } from "../integrations/pi/security.ts";
|
|
|
|
type OpenClawHandler = (event: Record<string, unknown>) => Promise<unknown>;
|
|
|
|
function mockFetch(): ReturnType<typeof vi.fn> {
|
|
const fetchMock = vi.fn(async () =>
|
|
new Response(JSON.stringify({ results: [] }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
return fetchMock;
|
|
}
|
|
|
|
function registerOpenClaw(baseUrl: string) {
|
|
const handlers = new Map<string, OpenClawHandler>();
|
|
const warn = vi.fn();
|
|
openclawPlugin.register({
|
|
pluginConfig: { base_url: baseUrl },
|
|
logger: { warn },
|
|
on(event: string, handler: OpenClawHandler) {
|
|
handlers.set(event, handler);
|
|
},
|
|
});
|
|
return { handlers, warn };
|
|
}
|
|
|
|
describe("OpenClaw plaintext bearer guard", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...originalEnv, AGENTMEMORY_SECRET: "secret" };
|
|
delete process.env["AGENTMEMORY_REQUIRE_HTTPS"];
|
|
mockFetch();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
it("keeps loopback HTTP silent", async () => {
|
|
const { handlers, warn } = registerOpenClaw("http://localhost:3111");
|
|
await handlers.get("before_agent_start")?.({ prompt: "recall auth work" });
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns once for non-loopback HTTP with a bearer secret", async () => {
|
|
const { handlers, warn } = registerOpenClaw("http://remote.example:3111");
|
|
await handlers.get("before_agent_start")?.({ prompt: "first" });
|
|
await handlers.get("before_agent_start")?.({ prompt: "second" });
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://remote.example:3111");
|
|
});
|
|
|
|
it("keeps HTTPS with a bearer secret silent", async () => {
|
|
const { handlers, warn } = registerOpenClaw("https://remote.example");
|
|
await handlers.get("before_agent_start")?.({ prompt: "recall auth work" });
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails before any request when HTTPS is required", () => {
|
|
process.env["AGENTMEMORY_REQUIRE_HTTPS"] = "1";
|
|
const fetchMock = mockFetch();
|
|
expect(() => registerOpenClaw("http://remote.example:3111")).toThrow(
|
|
/plaintext HTTP to http:\/\/remote\.example:3111/,
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("pi plaintext bearer guard", () => {
|
|
it("keeps loopback HTTP silent", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("http://127.0.0.1:3111", "secret");
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns once for non-loopback HTTP with a bearer secret", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("http://remote.example:3111", "secret");
|
|
guard("http://remote.example:3111", "secret");
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://remote.example:3111");
|
|
});
|
|
|
|
it("keeps HTTPS with a bearer secret silent", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("https://remote.example", "secret");
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails before callers can issue a request when HTTPS is required", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {
|
|
AGENTMEMORY_REQUIRE_HTTPS: "1",
|
|
});
|
|
expect(() => guard("http://remote.example:3111", "secret")).toThrow(
|
|
/plaintext HTTP to http:\/\/remote\.example:3111/,
|
|
);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats IPv6 loopback ([::1]) as loopback (URL parser strips brackets)", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("http://[::1]:3111", "secret");
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns for private LAN IPs — RFC1918 ranges are NOT loopback", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("http://192.168.1.50:3111", "secret");
|
|
guard("http://10.0.0.42:3111", "secret");
|
|
expect(warn).toHaveBeenCalledTimes(1); // warn-once
|
|
expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://192.168.1.50:3111");
|
|
});
|
|
|
|
it("does not warn when no secret is set — guard only fires when a bearer would actually be sent", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("http://remote.example:3111", "");
|
|
guard("http://remote.example:3111", undefined);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats hostnames that LOOK loopback but aren't (localhost.evil.com) as remote", () => {
|
|
const warn = vi.fn();
|
|
const guard = createPlaintextBearerAuthGuard(warn, {});
|
|
guard("http://localhost.evil.com:3111", "secret");
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("Hermes plaintext bearer guard", () => {
|
|
let home: string;
|
|
|
|
beforeEach(() => {
|
|
home = mkdtempSync(join(tmpdir(), "agentmemory-hermes-test-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
it("covers loopback, remote HTTP, HTTPS, and require-HTTPS behavior", () => {
|
|
const script = String.raw`
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
|
|
spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(mod)
|
|
|
|
for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"):
|
|
os.environ.pop(key, None)
|
|
|
|
warnings = []
|
|
mod._reset_plaintext_bearer_guard_for_tests()
|
|
mod._check_plaintext_bearer_guard("http://localhost:3111", "secret", warnings.append)
|
|
assert warnings == [], warnings
|
|
|
|
mod._reset_plaintext_bearer_guard_for_tests()
|
|
mod._check_plaintext_bearer_guard("http://remote.example:3111", "secret", warnings.append)
|
|
mod._check_plaintext_bearer_guard("http://remote.example:3111", "secret", warnings.append)
|
|
assert len(warnings) == 1, warnings
|
|
assert "plaintext HTTP to http://remote.example:3111" in warnings[0], warnings
|
|
|
|
warnings = []
|
|
mod._reset_plaintext_bearer_guard_for_tests()
|
|
mod._check_plaintext_bearer_guard("https://remote.example", "secret", warnings.append)
|
|
assert warnings == [], warnings
|
|
|
|
calls = []
|
|
def fake_urlopen(req, timeout=0):
|
|
calls.append(req)
|
|
raise AssertionError("request should not be sent")
|
|
|
|
mod.urlopen = fake_urlopen
|
|
os.environ["AGENTMEMORY_REQUIRE_HTTPS"] = "1"
|
|
try:
|
|
mod._api("http://remote.example:3111", "health", method="GET", secret="secret")
|
|
except RuntimeError as exc:
|
|
assert "plaintext HTTP to http://remote.example:3111" in str(exc), exc
|
|
else:
|
|
raise AssertionError("expected RuntimeError")
|
|
assert calls == [], calls
|
|
`;
|
|
const result = spawnSync("python3", ["-c", script], {
|
|
cwd: process.cwd(),
|
|
env: { ...process.env, HOME: home },
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
|
});
|
|
});
|