File size: 4,932 Bytes
253e783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// Link detection tests cover ordering, dedupe, markdown suppression, and SSRF hostname filtering.
import { describe, expect, it } from "vitest";
import { extractLinksFromMessage } from "./detect.js";

describe("extractLinksFromMessage", () => {
  it("extracts bare http/https URLs in order", () => {
    const links = extractLinksFromMessage("see https://a.example and http://b.test");
    expect(links).toEqual(["https://a.example", "http://b.test"]);
  });

  it("dedupes links and enforces maxLinks", () => {
    const links = extractLinksFromMessage("https://a.example https://a.example https://b.test", {
      maxLinks: 1,
    });
    expect(links).toEqual(["https://a.example"]);
  });

  it("ignores markdown links", () => {
    const links = extractLinksFromMessage("[doc](https://docs.example) https://bare.example");
    expect(links).toEqual(["https://bare.example"]);
  });

  it("ignores markdown links whose label contains brackets", () => {
    // The closing "]" inside the label must not break markdown stripping, otherwise
    // the citation URL leaks out as a bare link (with a stray trailing ")").
    const links = extractLinksFromMessage(
      "Check [my notes [v2]](https://internal.example/doc) for details",
    );
    expect(links).toStrictEqual([]);
  });

  it.each([
    ["double-quoted title", '[doc](https://docs.example "Docs")'],
    ["single-quoted title", "[doc](https://docs.example 'Docs')"],
    ["parenthesized title", "[doc](https://docs.example (Docs))"],
    ["escaped double quote", '[doc](https://docs.example "A \\"quoted\\" title")'],
    ["escaped single quote", "[doc](https://docs.example 'A \\'quoted\\' title')"],
    ["escaped parenthesis", "[doc](https://docs.example (a \\(paren\\) title))"],
    ["title line break", '[doc](https://docs.example "line one\nline two")'],
    ["angle destination", '[doc](<https://docs.example/a b> "Docs")'],
    ["balanced destination parentheses", "[doc](https://docs.example/a_(b))"],
    ["escaped destination parenthesis", String.raw`[doc](https://docs.example/a\)b)`],
  ])("ignores markdown links with a %s", (_name, markdownLink) => {
    expect(extractLinksFromMessage(`${markdownLink} https://bare.example`)).toStrictEqual([
      "https://bare.example",
    ]);
  });

  it.each([
    ["unterminated title", '[doc](https://docs.example "Docs)'],
    ["escaped closing delimiter", '[doc](https://docs.example "t\\")'],
  ])("does not strip a link with an %s", (_name, message) => {
    expect(extractLinksFromMessage(message)).toStrictEqual(["https://docs.example"]);
  });

  it("blocks 127.0.0.1", () => {
    const links = extractLinksFromMessage("http://127.0.0.1/test https://ok.test");
    expect(links).toEqual(["https://ok.test"]);
  });

  it("blocks localhost and common loopback addresses", () => {
    expect(extractLinksFromMessage("http://localhost/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://localhost.localdomain/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://foo.localhost/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://service.local/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://service.internal/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://0.0.0.0/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://[::1]/secret")).toStrictEqual([]);
  });

  it("blocks private network ranges", () => {
    expect(extractLinksFromMessage("http://10.0.0.1/internal")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://172.16.0.1/internal")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://192.168.1.1/internal")).toStrictEqual([]);
  });

  it("blocks link-local and cloud metadata addresses", () => {
    expect(extractLinksFromMessage("http://169.254.169.254/latest/meta-data/")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://169.254.1.1/test")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://metadata.google.internal/computeMetadata/v1/")).toEqual(
      [],
    );
  });

  it("blocks CGNAT range used by Tailscale", () => {
    expect(extractLinksFromMessage("http://100.100.50.1/test")).toStrictEqual([]);
  });

  it("blocks private and mapped IPv6 addresses", () => {
    expect(extractLinksFromMessage("http://[::ffff:127.0.0.1]/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://[2001:db8:1234::5efe:127.0.0.1]/secret")).toStrictEqual(
      [],
    );
    expect(extractLinksFromMessage("http://[fe80::1]/secret")).toStrictEqual([]);
    expect(extractLinksFromMessage("http://[fc00::1]/secret")).toStrictEqual([]);
  });

  it("allows legitimate public URLs", () => {
    expect(extractLinksFromMessage("https://example.com/page")).toEqual([
      "https://example.com/page",
    ]);
    expect(extractLinksFromMessage("https://8.8.8.8/dns")).toEqual(["https://8.8.8.8/dns"]);
  });
});