mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(nostr): harden profile mutation proxy guards
This commit is contained in:
@@ -240,6 +240,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config/compaction safeguard settings: regression-test `agents.defaults.compaction.recentTurnsPreserve` through `loadConfig()` and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.
|
||||
- iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.
|
||||
- CLI/Docs memory help accuracy: clarify `openclaw memory status --deep` behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.
|
||||
- Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (`x-forwarded-for` / `x-real-ip`) and rejecting `sec-fetch-site: cross-site`; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
|
||||
@@ -283,6 +283,36 @@ describe("nostr-profile-http", () => {
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{ name: "attacker" },
|
||||
{ headers: { "sec-fetch-site": "cross-site" } },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"PUT",
|
||||
"/api/channels/nostr/default/profile",
|
||||
{ name: "attacker" },
|
||||
{ headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
||||
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
|
||||
});
|
||||
@@ -431,6 +461,21 @@ describe("nostr-profile-http", () => {
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects import mutation when x-real-ip is non-loopback", async () => {
|
||||
const ctx = createMockContext();
|
||||
const handler = createNostrProfileHttpHandler(ctx);
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/api/channels/nostr/default/profile/import",
|
||||
{},
|
||||
{ headers: { "x-real-ip": "198.51.100.55" } },
|
||||
);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handler(req, res);
|
||||
expect(res._getStatusCode()).toBe(403);
|
||||
});
|
||||
|
||||
it("auto-merges when requested", async () => {
|
||||
const ctx = createMockContext({
|
||||
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
||||
|
||||
@@ -224,6 +224,51 @@ function isLoopbackOriginLike(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeIpCandidate(raw: string): string {
|
||||
const unquoted = raw.trim().replace(/^"|"$/g, "");
|
||||
const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
|
||||
if (bracketedWithOptionalPort) {
|
||||
return bracketedWithOptionalPort[1] ?? "";
|
||||
}
|
||||
const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
|
||||
if (ipv4WithPort) {
|
||||
return ipv4WithPort[1] ?? "";
|
||||
}
|
||||
return unquoted;
|
||||
}
|
||||
|
||||
function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean {
|
||||
const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
|
||||
if (forwardedFor) {
|
||||
for (const hop of forwardedFor.split(",")) {
|
||||
const candidate = normalizeIpCandidate(hop);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (!isLoopbackRemoteAddress(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const realIp = firstHeaderValue(req.headers["x-real-ip"]);
|
||||
if (realIp) {
|
||||
const candidate = normalizeIpCandidate(realIp);
|
||||
if (candidate && !isLoopbackRemoteAddress(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceLoopbackMutationGuards(
|
||||
ctx: NostrProfileHttpContext,
|
||||
req: IncomingMessage,
|
||||
@@ -237,15 +282,30 @@ function enforceLoopbackMutationGuards(
|
||||
return false;
|
||||
}
|
||||
|
||||
// If a proxy exposes client-origin headers showing a non-loopback client,
|
||||
// treat this as a remote request and deny mutation.
|
||||
if (hasNonLoopbackForwardedClient(req)) {
|
||||
ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase();
|
||||
if (secFetchSite === "cross-site") {
|
||||
ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
// CSRF guard: browsers send Origin/Referer on cross-site requests.
|
||||
const origin = req.headers.origin;
|
||||
const origin = firstHeaderValue(req.headers.origin);
|
||||
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
|
||||
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
return false;
|
||||
}
|
||||
|
||||
const referer = req.headers.referer ?? req.headers.referrer;
|
||||
const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
|
||||
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
|
||||
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
|
||||
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
||||
|
||||
Reference in New Issue
Block a user