feat: url normalization and http(s) allowlist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
62
src/lib/url.test.ts
Normal file
62
src/lib/url.test.ts
Normal 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
33
src/lib/url.ts
Normal 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() };
|
||||
}
|
||||
Reference in New Issue
Block a user