mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 14:57:00 +10:00
refactor: remove dead code, unused exports and redundant dependencies
- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code) - delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt) - delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool, schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers - inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough) - deduplicate template color helpers into shared/color-helpers - unexport 50+ internal-only symbols, remove dead export-map entries - replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterFieldValues } from "./field";
|
||||
|
||||
describe("filterFieldValues", () => {
|
||||
it("returns map of fields whose values are non-empty", () => {
|
||||
const fields = [{ key: "name" as const }, { key: "email" as const }];
|
||||
const result = filterFieldValues({ name: "Alice", email: "alice@example.com" }, ...fields);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("name")).toEqual({ key: "name" });
|
||||
expect(result.get("email")).toEqual({ key: "email" });
|
||||
});
|
||||
|
||||
it("filters out fields with empty string values", () => {
|
||||
const fields = [{ key: "name" as const }, { key: "email" as const }];
|
||||
const result = filterFieldValues({ name: "Alice", email: "" }, ...fields);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has("email")).toBe(false);
|
||||
expect(result.has("name")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters out fields with whitespace-only values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: " " }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with null values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: null }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with undefined values", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues({ name: undefined }, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("filters out fields with missing keys", () => {
|
||||
const fields = [{ key: "name" as const }];
|
||||
const result = filterFieldValues<"name", { key: "name" }>({}, ...fields);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves additional field properties on output", () => {
|
||||
const fields = [{ key: "name" as const, label: "Name", icon: "person" }];
|
||||
const result = filterFieldValues({ name: "Alice" }, ...fields);
|
||||
|
||||
expect(result.get("name")).toEqual({ key: "name", label: "Name", icon: "person" });
|
||||
});
|
||||
|
||||
it("returns empty map when no fields supplied", () => {
|
||||
const result = filterFieldValues({ name: "Alice" });
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves field order in iteration", () => {
|
||||
const fields = [{ key: "a" as const }, { key: "b" as const }, { key: "c" as const }];
|
||||
const result = filterFieldValues({ a: "1", b: "2", c: "3" }, ...fields);
|
||||
|
||||
const keys = [...result.keys()];
|
||||
expect(keys).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,3 @@
|
||||
type KeyedField<TKey extends string> = {
|
||||
key: TKey;
|
||||
};
|
||||
|
||||
type KeyValueMap<TKey extends string> = Partial<Record<TKey, string | null | undefined>>;
|
||||
|
||||
/**
|
||||
* Filters keyed field descriptors to only those whose corresponding value in the given
|
||||
* values object is a non-empty string.
|
||||
*
|
||||
* This is useful for rendering or processing only the fields with actual, non-blank content,
|
||||
* e.g., when displaying optional sections in a UI or assembling objects with present values.
|
||||
*
|
||||
* @param values - An object mapping keys to string values (which may be empty, null, or undefined)
|
||||
* @param fields - Field descriptors with { key: TKey }
|
||||
* @returns An array of fields whose corresponding values[key] is a non-empty string
|
||||
*/
|
||||
export function filterFieldValues<TKey extends string, TField extends KeyedField<TKey>>(
|
||||
values: KeyValueMap<TKey>,
|
||||
...fields: TField[]
|
||||
) {
|
||||
const filteredFields = fields.filter((field) => Boolean(values[field.key]?.trim()));
|
||||
return new Map(filteredFields.map((field) => [field.key, field] as const));
|
||||
}
|
||||
|
||||
export function unique<T>(items: T[]) {
|
||||
return items.filter((item, index) => items.indexOf(item) === index);
|
||||
return [...new Set(items)];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "./file";
|
||||
import { downloadWithAnchor, generateFilename } from "./file";
|
||||
|
||||
describe("generateFilename", () => {
|
||||
it("slugifies the prefix without extension", () => {
|
||||
@@ -88,45 +88,3 @@ describe("downloadWithAnchor", () => {
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadFromUrl", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn<typeof URL.createObjectURL>>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn<typeof URL.revokeObjectURL>>;
|
||||
let originalCreate: typeof URL.createObjectURL;
|
||||
let originalRevoke: typeof URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
originalCreate = URL.createObjectURL;
|
||||
originalRevoke = URL.revokeObjectURL;
|
||||
createObjectURLSpy = vi.fn<typeof URL.createObjectURL>(() => "blob:mock-url");
|
||||
revokeObjectURLSpy = vi.fn<typeof URL.revokeObjectURL>();
|
||||
URL.createObjectURL = createObjectURLSpy;
|
||||
URL.revokeObjectURL = revokeObjectURLSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
URL.createObjectURL = originalCreate;
|
||||
URL.revokeObjectURL = originalRevoke;
|
||||
});
|
||||
|
||||
it("fetches the URL and downloads the resulting blob", async () => {
|
||||
const mockBlob = new Blob(["data"], { type: "application/pdf" });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(new Response(mockBlob, { status: 200 })),
|
||||
) as unknown as typeof global.fetch;
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await downloadFromUrl("https://example.com/file.pdf", "file.pdf");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("https://example.com/file.pdf");
|
||||
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates fetch errors", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network down"))) as unknown as typeof global.fetch;
|
||||
await expect(downloadFromUrl("https://example.com", "x.pdf")).rejects.toThrow("network down");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,3 @@ export function downloadWithAnchor(blob: Blob, filename: string) {
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}
|
||||
|
||||
export async function downloadFromUrl(url: string, filename: string) {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export type Script = "hangul" | "kana" | "han-traditional" | "han-simplified" |
|
||||
// The CJK subset of `Script`. CJK needs extra per-character line breaking that
|
||||
// must NOT be applied to Arabic (cursive, joined letters) or Thai (combining
|
||||
// marks), so callers gate line-breaking on this rather than on `Script`.
|
||||
export const cjkScripts: readonly Script[] = ["hangul", "kana", "han-traditional", "han-simplified"];
|
||||
const cjkScripts: readonly Script[] = ["hangul", "kana", "han-traditional", "han-simplified"];
|
||||
|
||||
export function isCjkScript(script: Script): boolean {
|
||||
return cjkScripts.includes(script);
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isObject, sanitizeCss, sanitizeHtml } from "./sanitize";
|
||||
|
||||
describe("sanitizeHtml", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves allowed tags", () => {
|
||||
const result = sanitizeHtml("<p>Hello <strong>world</strong></p>");
|
||||
expect(result).toContain("<p>");
|
||||
expect(result).toContain("<strong>");
|
||||
expect(result).toContain("Hello");
|
||||
expect(result).toContain("world");
|
||||
});
|
||||
|
||||
it("strips disallowed tags like <script>", () => {
|
||||
const result = sanitizeHtml("<p>Hello</p><script>alert(1)</script>");
|
||||
expect(result).not.toContain("<script>");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
expect(result).toContain("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("strips disallowed attributes like onerror", () => {
|
||||
const result = sanitizeHtml('<img src="x" onerror="alert(1)" />');
|
||||
expect(result).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("strips iframe and object tags", () => {
|
||||
expect(sanitizeHtml("<iframe src='evil'></iframe>")).not.toContain("iframe");
|
||||
expect(sanitizeHtml("<object data='evil'></object>")).not.toContain("<object");
|
||||
});
|
||||
|
||||
it("preserves links with safe href", () => {
|
||||
const result = sanitizeHtml('<a href="https://example.com">Click</a>');
|
||||
expect(result).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("blocks javascript: URLs in href", () => {
|
||||
const result = sanitizeHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("preserves table structure tags", () => {
|
||||
const result = sanitizeHtml("<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>d</td></tr></tbody></table>");
|
||||
expect(result).toContain("<table>");
|
||||
expect(result).toContain("<thead>");
|
||||
expect(result).toContain("<tbody>");
|
||||
});
|
||||
|
||||
it("preserves list tags", () => {
|
||||
const result = sanitizeHtml("<ul><li>one</li><li>two</li></ul>");
|
||||
expect(result).toContain("<ul>");
|
||||
expect(result).toContain("<li>");
|
||||
});
|
||||
|
||||
it("preserves headings", () => {
|
||||
const result = sanitizeHtml("<h1>Title</h1><h2>Sub</h2>");
|
||||
expect(result).toContain("<h1>");
|
||||
expect(result).toContain("<h2>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeCss", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(sanitizeCss("")).toBe("");
|
||||
});
|
||||
|
||||
it("strips CSS comments", () => {
|
||||
const result = sanitizeCss("/* malicious */ body { color: red; }");
|
||||
expect(result).not.toContain("/*");
|
||||
expect(result).not.toContain("malicious");
|
||||
});
|
||||
|
||||
it("decodes hex CSS escapes before sanitizing", () => {
|
||||
// \\6a\\61... encodes "javascript" — the decoder should expose this and the javascript: pattern then strips
|
||||
const css = "a { background: \\6a avascript:alert(1) }";
|
||||
const result = sanitizeCss(css);
|
||||
expect(result.toLowerCase()).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips javascript: protocol", () => {
|
||||
const result = sanitizeCss("a { background: javascript:alert(1) }");
|
||||
expect(result.toLowerCase()).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips expression() syntax", () => {
|
||||
const result = sanitizeCss("a { width: expression(alert(1)) }");
|
||||
expect(result.toLowerCase()).not.toContain("expression(");
|
||||
});
|
||||
|
||||
it("strips behavior: declarations", () => {
|
||||
const result = sanitizeCss("a { behavior: url(#default#VML); }");
|
||||
expect(result.toLowerCase()).not.toContain("behavior:");
|
||||
});
|
||||
|
||||
it("strips -moz-binding declarations", () => {
|
||||
const result = sanitizeCss("a { -moz-binding: url(evil.xml#xss); }");
|
||||
expect(result.toLowerCase()).not.toContain("-moz-binding");
|
||||
});
|
||||
|
||||
it("strips @import directives", () => {
|
||||
const result = sanitizeCss("@import url('evil.css'); body { color: red; }");
|
||||
expect(result).not.toContain("@import");
|
||||
});
|
||||
|
||||
it("strips @font-face blocks", () => {
|
||||
const result = sanitizeCss("@font-face { font-family: x; src: url(evil); } body {}");
|
||||
expect(result).not.toContain("@font-face");
|
||||
});
|
||||
|
||||
it("strips url() values", () => {
|
||||
const result = sanitizeCss("a { background: url(evil.png); }");
|
||||
expect(result).not.toContain("url(");
|
||||
});
|
||||
|
||||
it("strips standalone src: declarations", () => {
|
||||
const result = sanitizeCss("src: url(x.woff);");
|
||||
expect(result).not.toContain("src:");
|
||||
});
|
||||
|
||||
it("preserves benign CSS", () => {
|
||||
const result = sanitizeCss("body { color: red; font-size: 14px; }");
|
||||
expect(result).toContain("color: red");
|
||||
expect(result).toContain("font-size: 14px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isObject", () => {
|
||||
it("returns true for plain objects", () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for arrays", () => {
|
||||
expect(isObject([])).toBe(false);
|
||||
expect(isObject([1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isObject(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isObject(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for primitives", () => {
|
||||
expect(isObject(0)).toBe(false);
|
||||
expect(isObject("string")).toBe(false);
|
||||
expect(isObject(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for class instances (objects)", () => {
|
||||
expect(isObject(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
it("narrows the type correctly", () => {
|
||||
const value: unknown = { foo: "bar" };
|
||||
if (isObject(value)) {
|
||||
// should compile-check: value has Record<string, unknown>
|
||||
expect(typeof value.foo).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { Config } from "dompurify";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const RICH_TEXT_CONFIG: Config = {
|
||||
ALLOWED_TAGS: [
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"span",
|
||||
"div",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"strike",
|
||||
"mark",
|
||||
"code",
|
||||
"pre",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"colgroup",
|
||||
"col",
|
||||
"a",
|
||||
"blockquote",
|
||||
],
|
||||
ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
const PLAIN_TEXT_SANITIZE_CONFIG: Config = {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
};
|
||||
|
||||
function sanitizePlainTextContent(value: string): string {
|
||||
return DOMPurify.sanitize(value, PLAIN_TEXT_SANITIZE_CONFIG) as string;
|
||||
}
|
||||
|
||||
function stripCssComments(value: string): string {
|
||||
if (!value) return "";
|
||||
return value.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
}
|
||||
|
||||
function decodeCssEscapes(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
return value.replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|\\(.)/g, (_match, hex, escapedChar) => {
|
||||
if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
|
||||
return escapedChar ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return "";
|
||||
return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string;
|
||||
}
|
||||
|
||||
export function sanitizeCss(css: string): string {
|
||||
if (!css) return "";
|
||||
|
||||
const normalized = decodeCssEscapes(stripCssComments(css));
|
||||
|
||||
const preSanitized = normalized
|
||||
.replace(/javascript\s*:/gi, "")
|
||||
.replace(/expression\s*\(/gi, "")
|
||||
.replace(/behavior\s*:[^;}]*/gi, "")
|
||||
.replace(/-moz-binding\s*:[^;}]*/gi, "");
|
||||
|
||||
const sanitized = preSanitized
|
||||
.replace(/@import[^;]*;/gi, "")
|
||||
.replace(/@font-face\s*\{[^}]*\}/gi, "")
|
||||
.replace(/\b(?:url|image-set|cross-fade)\s*\([^)]*\)/gi, "")
|
||||
.replace(/^\s*src\s*:[^;]*;?/gim, "");
|
||||
|
||||
return sanitizePlainTextContent(sanitized);
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isAllowedExternalUrl,
|
||||
isAllowedOAuthRedirectUri,
|
||||
isPrivateOrLoopbackHost,
|
||||
parseAllowedHostList,
|
||||
parseUrl,
|
||||
} from "./url-security.node";
|
||||
import { isAllowedOAuthRedirectUri, isPrivateOrLoopbackHost, parseUrl } from "./url-security.node";
|
||||
|
||||
describe("isPrivateOrLoopbackHost", () => {
|
||||
it.each([
|
||||
@@ -248,76 +242,6 @@ describe("parseUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAllowedHostList", () => {
|
||||
it("returns empty Set for undefined", () => {
|
||||
expect(parseAllowedHostList()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns empty Set for empty string", () => {
|
||||
expect(parseAllowedHostList("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("parses single host", () => {
|
||||
expect(parseAllowedHostList("example.com")).toEqual(new Set(["example.com"]));
|
||||
});
|
||||
|
||||
it("parses comma-separated list", () => {
|
||||
expect(parseAllowedHostList("example.com,api.example.com")).toEqual(new Set(["example.com", "api.example.com"]));
|
||||
});
|
||||
|
||||
it("trims whitespace around entries", () => {
|
||||
expect(parseAllowedHostList(" a.com , b.com ")).toEqual(new Set(["a.com", "b.com"]));
|
||||
});
|
||||
|
||||
it("lowercases entries", () => {
|
||||
expect(parseAllowedHostList("Example.COM")).toEqual(new Set(["example.com"]));
|
||||
});
|
||||
|
||||
it("filters out empty entries", () => {
|
||||
expect(parseAllowedHostList("a.com,,b.com,")).toEqual(new Set(["a.com", "b.com"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedExternalUrl", () => {
|
||||
const allowed = new Set(["api.example.com", "https://full.example.com"]);
|
||||
|
||||
it("returns false for malformed URLs", () => {
|
||||
expect(isAllowedExternalUrl("not a url", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-https protocols", () => {
|
||||
expect(isAllowedExternalUrl("http://api.example.com", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("ftp://api.example.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when URL contains credentials", () => {
|
||||
expect(isAllowedExternalUrl("https://user:pass@api.example.com", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://user@api.example.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for private/loopback hosts", () => {
|
||||
expect(isAllowedExternalUrl("https://localhost", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://127.0.0.1", allowed)).toBe(false);
|
||||
expect(isAllowedExternalUrl("https://192.168.1.1", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches by hostname", () => {
|
||||
expect(isAllowedExternalUrl("https://api.example.com/path", allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by full origin", () => {
|
||||
expect(isAllowedExternalUrl("https://full.example.com/x", allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-listed hostnames", () => {
|
||||
expect(isAllowedExternalUrl("https://evil.com", allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches case-insensitively in hostname", () => {
|
||||
expect(isAllowedExternalUrl("https://API.EXAMPLE.COM", allowed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedOAuthRedirectUri", () => {
|
||||
const trustedOrigins = ["https://app.example.com"];
|
||||
|
||||
|
||||
@@ -180,31 +180,6 @@ export function parseUrl(input: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAllowedHostList(value?: string) {
|
||||
if (!value) return new Set<string>();
|
||||
|
||||
const hosts = value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(hosts);
|
||||
}
|
||||
|
||||
export function isAllowedExternalUrl(input: string, allowedHosts: Set<string>) {
|
||||
const parsed = parseUrl(input);
|
||||
if (!parsed) return false;
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (parsed.username || parsed.password) return false;
|
||||
if (isPrivateOrLoopbackHost(parsed.hostname)) return false;
|
||||
|
||||
const hostname = normalizeHostname(parsed.hostname);
|
||||
if (allowedHosts.has(hostname)) return true;
|
||||
|
||||
const origin = parsed.origin.toLowerCase();
|
||||
return allowedHosts.has(origin);
|
||||
}
|
||||
|
||||
type OAuthRedirectUriOptions = {
|
||||
allowUnsafe?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user