Skip to content

Commit 7a2f818

Browse files
anatoly314claude
andauthored
fix(security): harden media-URL SSRF guard against IPv6 transition bypass (v0.22.5) (#46)
* fix(security): harden media-URL SSRF guard against IPv6 transition bypass Switch validateMediaUrl() from a range blocklist to a fail-closed allowlist (only ipaddr.js "unicast" is permitted). The blocklist missed the IPv6 transition ranges ipaddr.js can return (rfc6052/NAT64, rfc6145/SIIT, 6to4, teredo, benchmarking, amt), letting an attacker reach internal targets via a literal like http://[64:ff9b::169.254.169.254]/ that encodes a private IPv4. The allowlist closes the entire class, including any range future ipaddr.js versions add. (GHSA-5286-6cm5-w3qv) Also hardened, beyond the reported CVE: - Extract embedded IPv4 from IPv4-mapped (::ffff:0:0/96) AND deprecated IPv4-compatible (::/96) IPv6 before the range check, so an internal IPv4 wrapped in IPv6 (e.g. ::a9fe:a9fe) cannot bypass the guard. - Validate ALL resolved DNS records, not just the first, so a host with a public record and a private sibling is rejected. - allowedHosts matching normalizes IPs (embedded-IPv4 + IPv6 form) and handles bracketed IPv6 literals; the IP escape hatch requires every resolved address to be allowlisted. Adds 12 tests covering the transition ranges, embedded-IPv4 forms, multi-record resolution, and allowlist normalization (127 total, green). Reported by tonghuaroot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): v0.22.5 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9af424c commit 7a2f818

6 files changed

Lines changed: 396 additions & 131 deletions

File tree

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "0.4",
33
"name": "anki-mcp-server",
44
"display_name": "Anki MCP Server",
5-
"version": "0.22.4",
5+
"version": "0.22.5",
66
"license": "MIT",
77
"description": "Model Context Protocol server for Anki spaced repetition flashcard application",
88
"long_description": "Transform your Anki experience with natural language interaction - like having a private tutor. This MCP server enables AI assistants to interact with Anki, allowing them to explain concepts, make learning more engaging, provide context, and adapt to your learning style. Features include review sessions, deck management, note creation and editing, and more.\n\nRequires Anki with the AnkiConnect plugin installed.",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@ankimcp/anki-mcp-server",
33
"mcpName": "ai.ankimcp/anki-mcp-server",
4-
"version": "0.22.4",
4+
"version": "0.22.5",
55
"description": "Model Context Protocol server for Anki - enables AI assistants to interact with your Anki flashcards",
66
"author": "Anatoly Tarnavsky",
77
"private": false,

src/mcp/primitives/essential/tools/__tests__/store-media-file.tool.spec.ts

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ jest.mock("node:dns", () => {
1717
};
1818
});
1919

20-
const mockLookup = dns.promises.lookup as jest.MockedFunction<
21-
typeof dns.promises.lookup
20+
// Cast to the `{ all: true }` overload of dns.promises.lookup, which the
21+
// implementation uses (returns LookupAddress[] instead of a single address)
22+
const mockLookup = dns.promises.lookup as unknown as jest.MockedFunction<
23+
(
24+
hostname: string,
25+
options: dns.LookupAllOptions,
26+
) => Promise<dns.LookupAddress[]>
2227
>;
2328

2429
describe("StoreMediaFileTool", () => {
@@ -35,17 +40,11 @@ describe("StoreMediaFileTool", () => {
3540
AnkiConnectClient,
3641
) as jest.Mocked<AnkiConnectClient>;
3742

38-
mockLookup.mockResolvedValue({
39-
address: "93.184.216.34",
40-
family: 4,
41-
} as any);
43+
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
4244

4345
jest.clearAllMocks();
4446

45-
mockLookup.mockResolvedValue({
46-
address: "93.184.216.34",
47-
family: 4,
48-
} as any);
47+
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
4948
});
5049

5150
it("should store media file with base64 data", async () => {
@@ -277,10 +276,9 @@ describe("StoreMediaFileTool", () => {
277276
});
278277

279278
it("should reject URLs resolving to private IPs", async () => {
280-
mockLookup.mockResolvedValueOnce({
281-
address: "192.168.1.100",
282-
family: 4,
283-
} as any);
279+
mockLookup.mockResolvedValueOnce([
280+
{ address: "192.168.1.100", family: 4 },
281+
]);
284282

285283
const params = {
286284
filename: "internal.mp3",
@@ -298,10 +296,9 @@ describe("StoreMediaFileTool", () => {
298296
});
299297

300298
it("should allow URLs resolving to public IPs", async () => {
301-
mockLookup.mockResolvedValueOnce({
302-
address: "93.184.216.34",
303-
family: 4,
304-
} as any);
299+
mockLookup.mockResolvedValueOnce([
300+
{ address: "93.184.216.34", family: 4 },
301+
]);
305302

306303
const params = {
307304
filename: "public.mp3",
@@ -322,10 +319,9 @@ describe("StoreMediaFileTool", () => {
322319
it("should respect MEDIA_ALLOWED_HOSTS env var", async () => {
323320
const originalEnv = process.env.MEDIA_ALLOWED_HOSTS;
324321
try {
325-
mockLookup.mockResolvedValue({
326-
address: "192.168.1.100",
327-
family: 4,
328-
} as any);
322+
mockLookup.mockResolvedValue([
323+
{ address: "192.168.1.100", family: 4 },
324+
]);
329325

330326
const params = {
331327
filename: "internal.mp3",
@@ -387,10 +383,9 @@ describe("StoreMediaFileTool", () => {
387383

388384
describe("cloud metadata protection", () => {
389385
it("blocks cloud metadata endpoint (169.254.169.254)", async () => {
390-
mockLookup.mockResolvedValue({
391-
address: "169.254.169.254",
392-
family: 4,
393-
} as any);
386+
mockLookup.mockResolvedValue([
387+
{ address: "169.254.169.254", family: 4 },
388+
]);
394389

395390
const result = await tool.execute({
396391
filename: "metadata.txt",

src/mcp/primitives/essential/tools/__tests__/update-note-fields.tool.spec.ts

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ jest.mock("node:dns", () => {
2222
};
2323
});
2424

25-
const mockLookup = dns.promises.lookup as jest.MockedFunction<
26-
typeof dns.promises.lookup
25+
// Cast to the `{ all: true }` overload of dns.promises.lookup, which the
26+
// implementation uses (returns LookupAddress[] instead of a single address)
27+
const mockLookup = dns.promises.lookup as unknown as jest.MockedFunction<
28+
(
29+
hostname: string,
30+
options: dns.LookupAllOptions,
31+
) => Promise<dns.LookupAddress[]>
2732
>;
2833

2934
describe("UpdateNoteFieldsTool", () => {
@@ -43,10 +48,7 @@ describe("UpdateNoteFieldsTool", () => {
4348
jest.clearAllMocks();
4449

4550
// Default: resolve to a public IP so non-security tests aren't affected
46-
mockLookup.mockResolvedValue({
47-
address: "93.184.216.34",
48-
family: 4,
49-
} as any);
51+
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
5052
});
5153

5254
describe("updateNoteFields", () => {
@@ -390,10 +392,9 @@ describe("UpdateNoteFieldsTool", () => {
390392
});
391393

392394
it("should reject private IP URLs in audio[].url", async () => {
393-
mockLookup.mockResolvedValueOnce({
394-
address: "192.168.1.50",
395-
family: 4,
396-
} as any);
395+
mockLookup.mockResolvedValueOnce([
396+
{ address: "192.168.1.50", family: 4 },
397+
]);
397398

398399
const rawResult = await tool.updateNoteFields({
399400
note: {
@@ -418,10 +419,9 @@ describe("UpdateNoteFieldsTool", () => {
418419
});
419420

420421
it("should allow public URLs in audio[].url", async () => {
421-
mockLookup.mockResolvedValueOnce({
422-
address: "93.184.216.34",
423-
family: 4,
424-
} as any);
422+
mockLookup.mockResolvedValueOnce([
423+
{ address: "93.184.216.34", family: 4 },
424+
]);
425425

426426
ankiClient.invoke
427427
.mockResolvedValueOnce([mockNotes.spanish]) // notesInfo
@@ -449,8 +449,8 @@ describe("UpdateNoteFieldsTool", () => {
449449
it("rejects if any audio URL in array is malicious", async () => {
450450
// First lookup returns public IP, second returns private
451451
mockLookup
452-
.mockResolvedValueOnce({ address: "93.184.216.34", family: 4 })
453-
.mockResolvedValueOnce({ address: "192.168.1.1", family: 4 });
452+
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
453+
.mockResolvedValueOnce([{ address: "192.168.1.1", family: 4 }]);
454454

455455
const result = await tool.updateNoteFields({
456456
note: {
@@ -502,10 +502,7 @@ describe("UpdateNoteFieldsTool", () => {
502502
});
503503

504504
it("should reject private IP URLs in picture[].url", async () => {
505-
mockLookup.mockResolvedValueOnce({
506-
address: "10.0.0.1",
507-
family: 4,
508-
} as any);
505+
mockLookup.mockResolvedValueOnce([{ address: "10.0.0.1", family: 4 }]);
509506

510507
const rawResult = await tool.updateNoteFields({
511508
note: {
@@ -530,10 +527,9 @@ describe("UpdateNoteFieldsTool", () => {
530527
});
531528

532529
it("should allow public URLs in picture[].url", async () => {
533-
mockLookup.mockResolvedValueOnce({
534-
address: "151.101.1.67",
535-
family: 4,
536-
} as any);
530+
mockLookup.mockResolvedValueOnce([
531+
{ address: "151.101.1.67", family: 4 },
532+
]);
537533

538534
ankiClient.invoke
539535
.mockResolvedValueOnce([mockNotes.spanish]) // notesInfo
@@ -563,8 +559,8 @@ describe("UpdateNoteFieldsTool", () => {
563559
it("validates both audio and picture URLs in same request", async () => {
564560
// Audio URL is fine, picture URL resolves to private IP
565561
mockLookup
566-
.mockResolvedValueOnce({ address: "93.184.216.34", family: 4 })
567-
.mockResolvedValueOnce({ address: "10.0.0.1", family: 4 });
562+
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
563+
.mockResolvedValueOnce([{ address: "10.0.0.1", family: 4 }]);
568564

569565
const result = await tool.updateNoteFields({
570566
note: {
@@ -597,10 +593,7 @@ describe("UpdateNoteFieldsTool", () => {
597593

598594
describe("media filename sanitization", () => {
599595
it("sanitizes audio filename path traversal", async () => {
600-
mockLookup.mockResolvedValue({
601-
address: "93.184.216.34",
602-
family: 4,
603-
} as any);
596+
mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
604597

605598
// Mock the notesInfo call that happens before updateNoteFields
606599
ankiClient.invoke.mockImplementation(async (action: string) => {

0 commit comments

Comments
 (0)