fix: reject unsafe TechLog network paths
This commit is contained in:
@@ -185,9 +185,30 @@ function assertNever(value: never): never {
|
|||||||
throw new Error(`Unsupported content node: ${JSON.stringify(value)}`);
|
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 {
|
function isSafeLink(href: string): boolean {
|
||||||
|
if (href.includes("\\") || hasAsciiControlCharacter(href)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (href.startsWith("#")) return true;
|
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 {
|
try {
|
||||||
const url = new URL(href);
|
const url = new URL(href);
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ describe("Content Format v1", () => {
|
|||||||
"<script>alert(1)</script>",
|
"<script>alert(1)</script>",
|
||||||
"[x](javascript:alert(1))",
|
"[x](javascript:alert(1))",
|
||||||
"[x](//evil.example/path)",
|
"[x](//evil.example/path)",
|
||||||
|
"[x](/\\evil.example/path)",
|
||||||
|
"[x](/\\\\evil.example/path)",
|
||||||
|
"[x](</safe\u0001path>)",
|
||||||
|
"[x](</safe\u001fpath>)",
|
||||||
|
"[x](</safe\u007fpath>)",
|
||||||
"- outer\n - nested",
|
"- outer\n - nested",
|
||||||
"# level one",
|
"# level one",
|
||||||
"- [ ] task",
|
"- [ ] 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", () => {
|
it("generates stable Korean heading IDs and suffixes duplicates", () => {
|
||||||
const blocks = parseCaseContent("## 한글 API!\n\n## 한글 API?\n\n## !!!");
|
const blocks = parseCaseContent("## 한글 API!\n\n## 한글 API?\n\n## !!!");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user