From 8342fb14dc574eef1c08d1b9591aef7fc5325e44 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 15 Aug 2026 19:00:10 +0900 Subject: [PATCH] fix: reject unsafe TechLog network paths --- .../content-format/parse-case-content.ts | 23 ++++++++++++++++- .../features/tech-log/content-format.test.ts | 25 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/features/tech-log/domain/content-format/parse-case-content.ts b/src/features/tech-log/domain/content-format/parse-case-content.ts index 9efa574..578cefe 100644 --- a/src/features/tech-log/domain/content-format/parse-case-content.ts +++ b/src/features/tech-log/domain/content-format/parse-case-content.ts @@ -185,9 +185,30 @@ function assertNever(value: never): never { throw new Error(`Unsupported content node: ${JSON.stringify(value)}`); } +const trustedRelativeLinkOrigin = "https://techlog.invalid"; + +function hasAsciiControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + function isSafeLink(href: string): boolean { + if (href.includes("\\") || hasAsciiControlCharacter(href)) { + return false; + } if (href.startsWith("#")) return true; - if (href.startsWith("/")) return !href.startsWith("//"); + if (href.startsWith("/")) { + try { + return ( + new URL(href, `${trustedRelativeLinkOrigin}/`).origin === + trustedRelativeLinkOrigin + ); + } catch { + return false; + } + } try { const url = new URL(href); diff --git a/tests/features/tech-log/content-format.test.ts b/tests/features/tech-log/content-format.test.ts index bb9660c..085c80d 100644 --- a/tests/features/tech-log/content-format.test.ts +++ b/tests/features/tech-log/content-format.test.ts @@ -39,6 +39,11 @@ describe("Content Format v1", () => { "", "[x](javascript:alert(1))", "[x](//evil.example/path)", + "[x](/\\evil.example/path)", + "[x](/\\\\evil.example/path)", + "[x]()", + "[x]()", + "[x]()", "- outer\n - nested", "# level one", "- [ ] task", @@ -68,6 +73,26 @@ describe("Content Format v1", () => { } }); + it("keeps explicitly allowed links and same-origin paths", () => { + const [paragraph] = parseCaseContent( + "[fragment](#section) [path](/safe/path?q=one) [http](http://example.com/path) [https](https://example.com/path) [mail](mailto:test@example.com)", + ); + + expect(paragraph?.type).toBe("PARAGRAPH"); + if (paragraph?.type !== "PARAGRAPH") return; + expect( + paragraph.content + .filter((inline) => inline.type === "LINK") + .map((inline) => inline.href), + ).toEqual([ + "#section", + "/safe/path?q=one", + "http://example.com/path", + "https://example.com/path", + "mailto:test@example.com", + ]); + }); + it("generates stable Korean heading IDs and suffixes duplicates", () => { const blocks = parseCaseContent("## 한글 API!\n\n## 한글 API?\n\n## !!!");