first commit
This commit is contained in:
73
packages/core/src/utils/base64.ts
Normal file
73
packages/core/src/utils/base64.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Base64 encoding/decoding utilities.
|
||||
*
|
||||
* Uses native Uint8Array.prototype.toBase64 / Uint8Array.fromBase64 when
|
||||
* available (workerd, Node 26+, modern browsers), falls back to btoa/atob.
|
||||
*
|
||||
* All base64url encoding uses the { alphabet: "base64url" } option natively
|
||||
* or manual character replacement as fallback.
|
||||
*
|
||||
* Delete the fallback paths when the minimum Node version supports these
|
||||
* methods natively.
|
||||
*/
|
||||
|
||||
const hasNative =
|
||||
typeof Uint8Array.prototype.toBase64 === "function" &&
|
||||
typeof Uint8Array.fromBase64 === "function";
|
||||
|
||||
// Regex patterns for base64url character replacement
|
||||
const BASE64_PLUS_PATTERN = /\+/g;
|
||||
const BASE64_SLASH_PATTERN = /\//g;
|
||||
const BASE64_PADDING_PATTERN = /=+$/;
|
||||
const BASE64URL_DASH_PATTERN = /-/g;
|
||||
const BASE64URL_UNDERSCORE_PATTERN = /_/g;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standard base64 (for opaque tokens, cursors, Basic Auth, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Encode a UTF-8 string as standard base64. */
|
||||
export function encodeBase64(str: string): string {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
if (hasNative) return bytes.toBase64();
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Decode a standard base64 string to a UTF-8 string. */
|
||||
export function decodeBase64(base64: string): string {
|
||||
if (hasNative) return new TextDecoder().decode(Uint8Array.fromBase64(base64));
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url (for tokens, HMAC signatures, PKCE, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Encode bytes as base64url without padding. */
|
||||
export function encodeBase64url(bytes: Uint8Array): string {
|
||||
if (hasNative) return bytes.toBase64({ alphabet: "base64url", omitPadding: true });
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary)
|
||||
.replace(BASE64_PLUS_PATTERN, "-")
|
||||
.replace(BASE64_SLASH_PATTERN, "_")
|
||||
.replace(BASE64_PADDING_PATTERN, "");
|
||||
}
|
||||
|
||||
/** Decode a base64url string (with or without padding) to bytes. */
|
||||
export function decodeBase64url(encoded: string): Uint8Array {
|
||||
if (hasNative) return Uint8Array.fromBase64(encoded, { alphabet: "base64url" });
|
||||
const base64 = encoded
|
||||
.replace(BASE64URL_DASH_PATTERN, "+")
|
||||
.replace(BASE64URL_UNDERSCORE_PATTERN, "/");
|
||||
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
36
packages/core/src/utils/hash.ts
Normal file
36
packages/core/src/utils/hash.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* SHA-256 hash of a string, truncated to 16 hex chars (64 bits).
|
||||
* For cache invalidation / ETags — not for security.
|
||||
*/
|
||||
export async function hashString(content: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
|
||||
return Array.from(new Uint8Array(buf).slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute content hash using Web Crypto API
|
||||
*
|
||||
* Uses SHA-1 which is the fastest option in SubtleCrypto.
|
||||
* SHA-1 is cryptographically weak but fine for content deduplication
|
||||
* where we only need to detect identical files, not resist attacks.
|
||||
*
|
||||
* Returns hex string prefixed with "sha1:" for future-proofing
|
||||
*/
|
||||
export async function computeContentHash(content: Uint8Array | ArrayBuffer): Promise<string> {
|
||||
// SubtleCrypto.digest() requires BufferSource (ArrayBuffer | ArrayBufferView<ArrayBuffer>).
|
||||
// Uint8Array.buffer is ArrayBufferLike which may include SharedArrayBuffer in the type system,
|
||||
// so we ensure we have a plain ArrayBuffer.
|
||||
let buf: ArrayBuffer;
|
||||
if (content instanceof ArrayBuffer) {
|
||||
buf = content;
|
||||
} else {
|
||||
buf = new ArrayBuffer(content.byteLength);
|
||||
new Uint8Array(buf).set(content);
|
||||
}
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", buf);
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
const hashHex = Array.from(hashArray, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `sha1:${hashHex}`;
|
||||
}
|
||||
20
packages/core/src/utils/sanitize.ts
Normal file
20
packages/core/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
/**
|
||||
* Sanitize HTML content to prevent XSS attacks.
|
||||
*
|
||||
* Allows standard formatting tags, images, iframes (from specific providers),
|
||||
* and basic attributes.
|
||||
*/
|
||||
export function sanitizeContent(html: string): string {
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: [...sanitizeHtml.defaults.allowedTags, "img", "span", "iframe"],
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
"*": ["class", "id", "data-*"],
|
||||
iframe: ["src", "width", "height", "frameborder", "allow", "allowfullscreen"],
|
||||
img: ["src", "srcset", "alt", "title", "width", "height", "loading"],
|
||||
},
|
||||
allowedIframeHostnames: ["www.youtube.com", "player.vimeo.com"],
|
||||
});
|
||||
}
|
||||
29
packages/core/src/utils/slugify.ts
Normal file
29
packages/core/src/utils/slugify.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Regex patterns for slug normalization
|
||||
const DIACRITICS_PATTERN = /[\u0300-\u036f]/g;
|
||||
const WHITESPACE_UNDERSCORE_PATTERN = /[\s_]+/g;
|
||||
const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g;
|
||||
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
|
||||
const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g;
|
||||
const TRAILING_HYPHEN_PATTERN = /-$/;
|
||||
|
||||
/**
|
||||
* Convert a string to a URL-friendly slug.
|
||||
*
|
||||
* Handles unicode by normalizing to NFD and stripping diacritics,
|
||||
* so "café" becomes "cafe", "naïve" becomes "naive", etc.
|
||||
*/
|
||||
export function slugify(text: string, maxLength: number = 80): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(DIACRITICS_PATTERN, "")
|
||||
.replace(WHITESPACE_UNDERSCORE_PATTERN, "-")
|
||||
.replace(NON_ALPHANUMERIC_HYPHEN_PATTERN, "")
|
||||
.replace(MULTIPLE_HYPHENS_PATTERN, "-")
|
||||
.replace(LEADING_TRAILING_HYPHEN_PATTERN, "")
|
||||
.slice(0, maxLength)
|
||||
// Clean trailing hyphen from truncation
|
||||
.replace(TRAILING_HYPHEN_PATTERN, "")
|
||||
);
|
||||
}
|
||||
48
packages/core/src/utils/url.ts
Normal file
48
packages/core/src/utils/url.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* URL scheme validation utilities
|
||||
*
|
||||
* Prevents XSS via dangerous URL schemes (javascript:, data:, vbscript:, etc.)
|
||||
* by allowlisting known-safe schemes before rendering into href attributes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Matches URLs that are safe to render in href attributes.
|
||||
*
|
||||
* Allowed:
|
||||
* - http:// and https://
|
||||
* - mailto: and tel:
|
||||
* - Relative paths (starting with /)
|
||||
* - Fragment links (starting with #)
|
||||
* - Protocol-relative URLs are NOT allowed (starting with //) as they can
|
||||
* redirect to attacker-controlled hosts.
|
||||
*/
|
||||
const SAFE_URL_SCHEME_RE = /^(https?:|mailto:|tel:|\/(?!\/)|#)/i;
|
||||
|
||||
/**
|
||||
* Returns the URL unchanged if it uses a safe scheme, otherwise returns "#".
|
||||
*
|
||||
* Use this at the render layer as the primary defense against XSS via
|
||||
* dangerous URL schemes like `javascript:`, `data:`, or `vbscript:`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* sanitizeHref("https://example.com") // "https://example.com"
|
||||
* sanitizeHref("/about") // "/about"
|
||||
* sanitizeHref("#section") // "#section"
|
||||
* sanitizeHref("mailto:a@b.com") // "mailto:a@b.com"
|
||||
* sanitizeHref("javascript:alert(1)") // "#"
|
||||
* sanitizeHref("data:text/html,<script>") // "#"
|
||||
* sanitizeHref("") // "#"
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeHref(url: string | undefined | null): string {
|
||||
if (!url) return "#";
|
||||
return SAFE_URL_SCHEME_RE.test(url) ? url : "#";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the URL uses a safe scheme for rendering in href attributes.
|
||||
*/
|
||||
export function isSafeHref(url: string): boolean {
|
||||
return SAFE_URL_SCHEME_RE.test(url);
|
||||
}
|
||||
Reference in New Issue
Block a user