@openclaw/nostr 2026.2.17 → 2026.2.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/package.json +1 -1
- package/src/nostr-profile-http.test.ts +17 -0
- package/src/nostr-profile-http.ts +6 -79
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -279,6 +279,23 @@ describe("nostr-profile-http", () => {
|
|
|
279
279
|
expect(data.error).toContain("private");
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
it("rejects ISATAP-embedded private IPv4 in picture URL", async () => {
|
|
283
|
+
const ctx = createMockContext();
|
|
284
|
+
const handler = createNostrProfileHttpHandler(ctx);
|
|
285
|
+
const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", {
|
|
286
|
+
name: "hacker",
|
|
287
|
+
picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg",
|
|
288
|
+
});
|
|
289
|
+
const res = createMockResponse();
|
|
290
|
+
|
|
291
|
+
await handler(req, res);
|
|
292
|
+
|
|
293
|
+
expect(res._getStatusCode()).toBe(400);
|
|
294
|
+
const data = JSON.parse(res._getData());
|
|
295
|
+
expect(data.ok).toBe(false);
|
|
296
|
+
expect(data.error).toContain("private");
|
|
297
|
+
});
|
|
298
|
+
|
|
282
299
|
it("rejects non-https URLs", async () => {
|
|
283
300
|
const ctx = createMockContext();
|
|
284
301
|
const handler = createNostrProfileHttpHandler(ctx);
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isBlockedHostnameOrIp,
|
|
13
|
+
readJsonBodyWithLimit,
|
|
14
|
+
requestBodyErrorToText,
|
|
15
|
+
} from "openclaw/plugin-sdk";
|
|
12
16
|
import { z } from "zod";
|
|
13
17
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
14
18
|
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
|
@@ -98,72 +102,6 @@ async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Prom
|
|
|
98
102
|
// SSRF Protection
|
|
99
103
|
// ============================================================================
|
|
100
104
|
|
|
101
|
-
// Block common private/internal hostnames (quick string check)
|
|
102
|
-
const BLOCKED_HOSTNAMES = new Set([
|
|
103
|
-
"localhost",
|
|
104
|
-
"localhost.localdomain",
|
|
105
|
-
"127.0.0.1",
|
|
106
|
-
"::1",
|
|
107
|
-
"[::1]",
|
|
108
|
-
"0.0.0.0",
|
|
109
|
-
]);
|
|
110
|
-
|
|
111
|
-
// Check if an IP address (resolved) is in a private range
|
|
112
|
-
function isPrivateIp(ip: string): boolean {
|
|
113
|
-
// Handle IPv4
|
|
114
|
-
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
115
|
-
if (ipv4Match) {
|
|
116
|
-
const [, a, b] = ipv4Match.map(Number);
|
|
117
|
-
// 127.0.0.0/8 (loopback)
|
|
118
|
-
if (a === 127) {
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
121
|
-
// 10.0.0.0/8 (private)
|
|
122
|
-
if (a === 10) {
|
|
123
|
-
return true;
|
|
124
|
-
}
|
|
125
|
-
// 172.16.0.0/12 (private)
|
|
126
|
-
if (a === 172 && b >= 16 && b <= 31) {
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
// 192.168.0.0/16 (private)
|
|
130
|
-
if (a === 192 && b === 168) {
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
// 169.254.0.0/16 (link-local)
|
|
134
|
-
if (a === 169 && b === 254) {
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
137
|
-
// 0.0.0.0/8
|
|
138
|
-
if (a === 0) {
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Handle IPv6
|
|
145
|
-
const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, "");
|
|
146
|
-
// ::1 (loopback)
|
|
147
|
-
if (ipLower === "::1") {
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
// fe80::/10 (link-local)
|
|
151
|
-
if (ipLower.startsWith("fe80:")) {
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
// fc00::/7 (unique local)
|
|
155
|
-
if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) {
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
// ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4
|
|
159
|
-
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
160
|
-
if (v4Mapped) {
|
|
161
|
-
return isPrivateIp(v4Mapped[1]);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
105
|
function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
|
|
168
106
|
try {
|
|
169
107
|
const url = new URL(urlStr);
|
|
@@ -174,18 +112,7 @@ function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: s
|
|
|
174
112
|
|
|
175
113
|
const hostname = url.hostname.toLowerCase();
|
|
176
114
|
|
|
177
|
-
|
|
178
|
-
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
|
179
|
-
return { ok: false, error: "URL must not point to private/internal addresses" };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Check if hostname is an IP address directly
|
|
183
|
-
if (isPrivateIp(hostname)) {
|
|
184
|
-
return { ok: false, error: "URL must not point to private/internal addresses" };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Block suspicious TLDs that resolve to localhost
|
|
188
|
-
if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
|
|
115
|
+
if (isBlockedHostnameOrIp(hostname)) {
|
|
189
116
|
return { ok: false, error: "URL must not point to private/internal addresses" };
|
|
190
117
|
}
|
|
191
118
|
|