feat: url normalization and http(s) allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-05-08 17:44:44 -04:00
parent 1e4bd8082b
commit 45adba1643
5 changed files with 483 additions and 3 deletions

62
src/lib/url.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import { normalizeUrl, validateUrl } from "./url";
describe("normalizeUrl", () => {
it("prepends https:// to a bare domain", () => {
expect(normalizeUrl("twitch.tv/foo")).toBe("https://twitch.tv/foo");
});
it("preserves existing https://", () => {
expect(normalizeUrl("https://example.com")).toBe("https://example.com");
});
it("preserves existing http://", () => {
expect(normalizeUrl("http://example.com")).toBe("http://example.com");
});
it("trims surrounding whitespace", () => {
expect(normalizeUrl(" twitch.tv ")).toBe("https://twitch.tv");
});
});
describe("validateUrl", () => {
it("accepts a normal https URL", () => {
expect(validateUrl("https://twitch.tv/foo")).toEqual({ ok: true, url: "https://twitch.tv/foo" });
});
it("normalizes a bare domain", () => {
expect(validateUrl("twitch.tv")).toEqual({ ok: true, url: "https://twitch.tv/" });
});
it("rejects empty input", () => {
expect(validateUrl("")).toEqual({ ok: false, error: "Enter a URL" });
});
it("rejects whitespace-only input", () => {
expect(validateUrl(" ")).toEqual({ ok: false, error: "Enter a URL" });
});
it("rejects javascript: scheme", () => {
expect(validateUrl("javascript:alert(1)")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects file:// scheme", () => {
expect(validateUrl("file:///c:/secret.html")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects tauri:// scheme", () => {
expect(validateUrl("tauri://localhost/index.html")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects data: scheme", () => {
expect(validateUrl("data:text/html,hi")).toEqual({
ok: false,
error: "Only http and https URLs are allowed",
});
});
it("rejects unparsable input", () => {
expect(validateUrl("not a url")).toEqual({
ok: false,
error: "That doesn't look like a URL",
});
});
});

33
src/lib/url.ts Normal file
View File

@@ -0,0 +1,33 @@
export type UrlValidation =
| { ok: true; url: string }
| { ok: false; error: string };
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
export function normalizeUrl(raw: string): string {
const trimmed = raw.trim();
if (trimmed === "") return trimmed;
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function validateUrl(raw: string): UrlValidation {
const trimmed = raw.trim();
if (trimmed === "") return { ok: false, error: "Enter a URL" };
const candidate = normalizeUrl(trimmed);
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return { ok: false, error: "That doesn't look like a URL" };
}
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
return { ok: false, error: "Only http and https URLs are allowed" };
}
if (!parsed.hostname) {
return { ok: false, error: "That doesn't look like a URL" };
}
return { ok: true, url: parsed.toString() };
}