diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8378a981..6a970e5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: read + concurrency: group: deploy-sites cancel-in-progress: true diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index a6babbe1..37b7c2bb 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -3,6 +3,9 @@ name: PR Checks on: pull_request: +permissions: + contents: read + concurrency: group: pr-checks-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80fd8be7..89c51b5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,9 @@ on: - main pull_request: +permissions: + contents: read + concurrency: group: tests-${{ github.ref }} cancel-in-progress: true diff --git a/packages/admin-ui/src/pages/Branding.tsx b/packages/admin-ui/src/pages/Branding.tsx index 42e66721..9aefd437 100644 --- a/packages/admin-ui/src/pages/Branding.tsx +++ b/packages/admin-ui/src/pages/Branding.tsx @@ -30,6 +30,8 @@ type BrandingTab = ThemeMode | "language"; type PreviewScreen = "login" | "apps" | "security" | "authorize" | "profile"; type BrandingImage = { data: string | null; mimeType: string | null }; type BrandingIdentity = { title: string; tagline: string }; +const allowedBrandingImageTypes = new Set(["image/png", "image/jpeg", "image/x-icon"]); +const brandingImageAccept = "image/png,image/jpeg,image/x-icon"; const colorFields = [ { key: "brandColor", label: "Brand Color" }, @@ -376,7 +378,7 @@ export default function Branding() { if (event.origin !== window.location.origin) return; if (event.source !== iframeRef.current?.contentWindow) return; const data = event.data as { type?: string; theme?: unknown } | null; - if (!data || data.type !== "da:theme-changed") return; + if (data?.type !== "da:theme-changed") return; if (data.theme === "light" || data.theme === "dark") setActiveMode(data.theme); }; window.addEventListener("message", onMessage); @@ -388,6 +390,10 @@ export default function Branding() { toast({ title: "Image too large (max 2MB)", variant: "destructive" }); return; } + if (!allowedBrandingImageTypes.has(file.type)) { + toast({ title: "Invalid image type", variant: "destructive" }); + return; + } try { const buf = await file.arrayBuffer(); setter({ data: arrayBufferToBase64(buf), mimeType: file.type }); @@ -512,7 +518,7 @@ export default function Branding() {
{ const f = e.target.files?.[0]; if (f) onPickImage(f, setLogo); @@ -551,7 +557,7 @@ export default function Branding() {
{ const f = e.target.files?.[0]; if (f) onPickImage(f, setFavicon); diff --git a/packages/api/src/controllers/admin/settingsUpdate.ts b/packages/api/src/controllers/admin/settingsUpdate.ts index 69c8935d..8be50bcf 100644 --- a/packages/api/src/controllers/admin/settingsUpdate.ts +++ b/packages/api/src/controllers/admin/settingsUpdate.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { z } from "zod/v4"; import { ForbiddenError, ValidationError } from "../../errors.ts"; import { genericErrors } from "../../http/openapi-helpers.ts"; +import { validateImageBase64 } from "../../services/branding.ts"; import { isEmailSendingAvailable } from "../../services/email.ts"; import { requireSession } from "../../services/sessions.ts"; import { getSetting, setSetting } from "../../services/settings.ts"; @@ -86,6 +87,8 @@ async function updateSettingsHandler( await validatePasswordResetEnable(context, data.value); } else if (data.key === "email.smtp.enabled") { await validateSmtpEnable(context, data.value); + } else if (isBrandingImageSetting(data.key)) { + validateBrandingImageSetting(data.value); } // Get the old value for audit logging @@ -113,6 +116,33 @@ async function updateSettingsHandler( }); } +function isBrandingImageSetting(key: string): boolean { + return [ + "branding.logo", + "branding.logo_dark", + "branding.favicon", + "branding.favicon_dark", + ].includes(key); +} + +function validateBrandingImageSetting(value: unknown): void { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ValidationError("Branding image must be an object"); + } + const image = value as { data?: unknown; mimeType?: unknown }; + const hasData = typeof image.data === "string" && image.data.length > 0; + const hasMimeType = typeof image.mimeType === "string" && image.mimeType.length > 0; + if (!hasData && !hasMimeType) return; + if (!hasData || !hasMimeType) { + throw new ValidationError("Branding image data and MIME type are required"); + } + try { + validateImageBase64(image.data as string, image.mimeType as string); + } catch (error) { + throw new ValidationError(error instanceof Error ? error.message : "Invalid image"); + } +} + function validateSmtpPort(value: unknown): void { if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) { throw new ValidationError("SMTP port must be between 1 and 65535"); diff --git a/packages/api/src/controllers/user/usersDirectory.ts b/packages/api/src/controllers/user/usersDirectory.ts index c209b783..4cdfd646 100644 --- a/packages/api/src/controllers/user/usersDirectory.ts +++ b/packages/api/src/controllers/user/usersDirectory.ts @@ -11,6 +11,7 @@ import type { Context, ControllerSchema } from "../../types.ts"; import { sendJson } from "../../utils/http.ts"; const usersReadPermissionKey = "darkauth.users:read"; +const maxAuthorizationHeaderLength = 16_384; export function hasRequiredPermission(permissions: unknown): boolean { return ( @@ -63,17 +64,50 @@ function hasMatchingAudience(payload: Record, clientId: string) return false; } +function isBearerSeparator(charCode: number): boolean { + return charCode === 0x20 || charCode === 0x09; +} + +function isBearerAuthAttempt(auth: string): boolean { + if (!auth.startsWith("Bearer")) return false; + return auth.length === "Bearer".length || isBearerSeparator(auth.charCodeAt("Bearer".length)); +} + +export function parseBearerToken(auth: string): string | null { + if (auth.length === 0 || auth.length > maxAuthorizationHeaderLength) return null; + if (!auth.startsWith("Bearer")) return null; + + let tokenStart = "Bearer".length; + if (tokenStart >= auth.length || !isBearerSeparator(auth.charCodeAt(tokenStart))) return null; + + while (tokenStart < auth.length && isBearerSeparator(auth.charCodeAt(tokenStart))) { + tokenStart += 1; + } + + if (tokenStart >= auth.length) return null; + + for (let index = tokenStart; index < auth.length; index += 1) { + if (isBearerSeparator(auth.charCodeAt(index))) return null; + } + + return auth.slice(tokenStart); +} + async function requireUsersReadPermission( context: Context, request: IncomingMessage ): Promise { const auth = request.headers.authorization || ""; - const tokenMatch = /^Bearer\s+(.+)$/.exec(auth); + const bearerToken = parseBearerToken(auth); + + if (!bearerToken && isBearerAuthAttempt(auth)) { + throw new UnauthorizedError("Invalid bearer token"); + } - if (tokenMatch?.[1]) { + if (bearerToken) { const JWKS = createRemoteJWKSet(new URL(`${context.config.issuer}/.well-known/jwks.json`)); try { - const verified = await jwtVerify(tokenMatch[1], JWKS, { issuer: context.config.issuer }); + const verified = await jwtVerify(bearerToken, JWKS, { issuer: context.config.issuer }); const payload = verified.payload as Record; const mode = resolveUsersReadModeFromPayload(payload); diff --git a/packages/api/src/controllers/usersDirectory.test.ts b/packages/api/src/controllers/usersDirectory.test.ts index a90d54de..8f5363dc 100644 --- a/packages/api/src/controllers/usersDirectory.test.ts +++ b/packages/api/src/controllers/usersDirectory.test.ts @@ -3,6 +3,7 @@ import { test } from "node:test"; import { hasRequiredPermission, hasRequiredScope, + parseBearerToken, resolveUsersReadModeFromPayload, } from "./user/usersDirectory.ts"; @@ -39,3 +40,17 @@ test("resolveUsersReadModeFromPayload returns null when missing required permiss }); assert.equal(mode, null); }); + +test("parseBearerToken extracts a bounded bearer token without regex parsing", () => { + assert.equal(parseBearerToken("Bearer access-token"), "access-token"); + assert.equal(parseBearerToken("Bearer access-token"), "access-token"); + assert.equal(parseBearerToken("Bearer\taccess-token"), "access-token"); +}); + +test("parseBearerToken rejects missing, padded, and oversized tokens", () => { + assert.equal(parseBearerToken(""), null); + assert.equal(parseBearerToken("Bearer"), null); + assert.equal(parseBearerToken("Bearer "), null); + assert.equal(parseBearerToken("Bearer access-token "), null); + assert.equal(parseBearerToken(`Bearer ${"a".repeat(16_379)}`), null); +}); diff --git a/packages/api/src/models/federation.test.ts b/packages/api/src/models/federation.test.ts index f4ef588c..e3d53f44 100644 --- a/packages/api/src/models/federation.test.ts +++ b/packages/api/src/models/federation.test.ts @@ -62,7 +62,7 @@ async function createContext() { return { context, cleanup }; } -const publicLookup = async () => [{ address: "203.0.113.10" }]; +const publicLookup = async () => [{ address: "93.184.216.34" }]; const metadata = { issuer: "https://idp.example.com", @@ -394,3 +394,41 @@ test("OIDC discovery blocks local and private issuer hosts before fetch", async assert.equal(fetchImpl.mock.callCount(), 0); }); + +test("OIDC discovery blocks non-public resolved and translated addresses before fetch", async () => { + const fetchImpl = mock.fn(async () => ({ ok: true, json: async () => metadata }) as Response); + + await assert.rejects( + () => + discoverOidcMetadata("https://idp.example.com", fetchImpl, async () => [ + { address: "100.64.0.1" }, + ]), + /issuer host is not allowed/ + ); + await assert.rejects( + () => discoverOidcMetadata("https://[64:ff9b::7f00:1]", fetchImpl, publicLookup), + /issuer host is not allowed/ + ); + + assert.equal(fetchImpl.mock.callCount(), 0); +}); + +test("OIDC discovery rejects ambiguous issuer URLs before fetch", async () => { + const fetchImpl = mock.fn(async () => ({ ok: true, json: async () => metadata }) as Response); + + await assert.rejects( + () => discoverOidcMetadata("https://user:pass@idp.example.com", fetchImpl, publicLookup), + /issuer must not include credentials/ + ); + await assert.rejects( + () => + discoverOidcMetadata( + "https://idp.example.com?next=https://127.0.0.1", + fetchImpl, + publicLookup + ), + /issuer must not include a query string/ + ); + + assert.equal(fetchImpl.mock.callCount(), 0); +}); diff --git a/packages/api/src/models/federation.ts b/packages/api/src/models/federation.ts index b27c200e..0411c7f3 100644 --- a/packages/api/src/models/federation.ts +++ b/packages/api/src/models/federation.ts @@ -798,14 +798,60 @@ function isBlockedAddress(address: string) { const normalized = address.toLowerCase().replace(/^\[|\]$/g, ""); if (isBlockedIpv4(normalized)) return true; if (isIP(normalized) !== 6) return false; - const first = Number.parseInt(normalized.split(":")[0] || "0", 16); - return ( - normalized === "::" || - normalized === "::1" || - normalized.startsWith("::ffff:") || - (first >= 0xfc00 && first <= 0xfdff) || - (first >= 0xfe80 && first <= 0xfebf) - ); + const value = parseIpv6(normalized); + if (value === null) return true; + return IPV6_BLOCKED_RANGES.some(([base, bits]) => isIpv6InRange(value, base, bits)); +} + +const IPV6_BLOCKED_RANGES = [ + [parseIpv6("::") as bigint, 96], + [parseIpv6("::ffff:0:0") as bigint, 96], + [parseIpv6("64:ff9b::") as bigint, 96], + [parseIpv6("64:ff9b:1::") as bigint, 48], + [parseIpv6("100::") as bigint, 64], + [parseIpv6("2001::") as bigint, 23], + [parseIpv6("2002::") as bigint, 16], + [parseIpv6("fc00::") as bigint, 7], + [parseIpv6("fe80::") as bigint, 10], + [parseIpv6("ff00::") as bigint, 8], +] as const; + +function isIpv6InRange(value: bigint, base: bigint, bits: number) { + const shift = 128n - BigInt(bits); + return value >> shift === base >> shift; +} + +function parseIpv6(address: string) { + const withoutZone = address.split("%")[0] || ""; + const expanded = expandEmbeddedIpv4(withoutZone); + if (!expanded) return null; + const halves = expanded.split("::"); + if (halves.length > 2) return null; + const left = halves[0] ? halves[0].split(":") : []; + const right = halves[1] ? halves[1].split(":") : []; + const missing = halves.length === 1 ? 0 : 8 - left.length - right.length; + if (missing < 0) return null; + const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right]; + if (groups.length !== 8) return null; + let value = 0n; + for (const group of groups) { + if (!/^[0-9a-f]{1,4}$/i.test(group)) return null; + value = (value << 16n) + BigInt(Number.parseInt(group, 16)); + } + return value; +} + +function expandEmbeddedIpv4(address: string) { + if (!address.includes(".")) return address; + const index = address.lastIndexOf(":"); + if (index < 0) return null; + const ipv4 = address.slice(index + 1); + if (isIP(ipv4) !== 4) return null; + const parts = ipv4.split(".").map((part) => Number.parseInt(part, 10)); + const [a = 0, b = 0, c = 0, d = 0] = parts; + const high = (a << 8) + b; + const low = (c << 8) + d; + return `${address.slice(0, index)}:${high.toString(16)}:${low.toString(16)}`; } function isBlockedIpv4(address: string) { @@ -815,10 +861,14 @@ function isBlockedIpv4(address: string) { return ( a === 0 || a === 10 || + (a === 100 && b >= 64 && b <= 127) || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 ); } @@ -1250,6 +1300,7 @@ async function encryptSecret(context: Context, secret: string | null | undefined function normalizeIssuer(value: string) { const url = normalizeHttpsUrl(value, "issuer"); + if (new URL(url).search) throw new ValidationError("issuer must not include a query string"); return url.endsWith("/") ? url.slice(0, -1) : url; } @@ -1258,6 +1309,8 @@ function normalizeHttpsUrl(value: string, name: string) { const url = new URL(value); const host = url.hostname.toLowerCase(); const local = host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (url.username || url.password) + throw new ValidationError(`${name} must not include credentials`); if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) { throw new ValidationError(`${name} must use https`); } diff --git a/packages/api/src/models/passwordResetTokens.test.ts b/packages/api/src/models/passwordResetTokens.test.ts index 99e92f0a..de14818b 100644 --- a/packages/api/src/models/passwordResetTokens.test.ts +++ b/packages/api/src/models/passwordResetTokens.test.ts @@ -11,6 +11,7 @@ import type { Context } from "../types.ts"; import { createPasswordResetToken, getActivePasswordResetToken, + hashPasswordResetToken, invalidateActivePasswordResetTokens, type PasswordResetTokenRow, } from "./passwordResetTokens.ts"; @@ -63,7 +64,11 @@ async function findActiveToken( test("createPasswordResetToken stores only token hashes and invalidates older active tokens", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-password-reset-token-test-")); const { db, client, close } = await createPglite(directory); - const context = { db, logger: createLogger() } as Context; + const context = { + db, + logger: createLogger(), + config: { kekPassphrase: "test-password-reset-pepper" }, + } as Context; try { await ensurePasswordResetTokenTable(client); @@ -92,6 +97,8 @@ test("createPasswordResetToken stores only token hashes and invalidates older ac assert.equal(rows.filter((row) => row.consumedAt === null).length, 1); assert.ok(rows.every((row) => row.tokenHash !== first.token)); assert.ok(rows.every((row) => row.tokenHash !== second.token)); + assert.ok(rows.some((row) => row.tokenHash === hashPasswordResetToken(context, first.token))); + assert.ok(rows.some((row) => row.tokenHash === hashPasswordResetToken(context, second.token))); assert.equal(await findActiveToken(context, first.token), null); const active = await findActiveToken(context, second.token); @@ -106,7 +113,11 @@ test("createPasswordResetToken stores only token hashes and invalidates older ac test("invalidateActivePasswordResetTokens and expiry prevent token validation", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-password-reset-token-test-")); const { db, client, close } = await createPglite(directory); - const context = { db, logger: createLogger() } as Context; + const context = { + db, + logger: createLogger(), + config: { kekPassphrase: "test-password-reset-pepper" }, + } as Context; try { await ensurePasswordResetTokenTable(client); diff --git a/packages/api/src/models/passwordResetTokens.ts b/packages/api/src/models/passwordResetTokens.ts index fa986106..90623e30 100644 --- a/packages/api/src/models/passwordResetTokens.ts +++ b/packages/api/src/models/passwordResetTokens.ts @@ -16,7 +16,8 @@ export interface PasswordResetTokenRow { } export function hashPasswordResetToken(context: Context, token: string): string { - const pepper = context.config?.kekPassphrase || context.config?.installToken || "DarkAuth"; + const pepper = context.config?.kekPassphrase; + if (!pepper) throw new ValidationError("Password reset token pepper is not configured"); return createHmac("sha256", pepper).update(token).digest("base64url"); } diff --git a/packages/api/src/models/trustedDevices.ts b/packages/api/src/models/trustedDevices.ts index 6d0cf2f9..e01e8897 100644 --- a/packages/api/src/models/trustedDevices.ts +++ b/packages/api/src/models/trustedDevices.ts @@ -1,9 +1,9 @@ -import type { webcrypto } from "node:crypto"; +import { randomInt, type webcrypto } from "node:crypto"; import { and, eq, gt, isNull } from "drizzle-orm"; import { deviceApprovalRequests, keyEnvelopes, trustedDevices } from "../db/schema.ts"; import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from "../errors.ts"; import type { Context } from "../types.ts"; -import { generateRandomBytes, generateRandomString, sha256Base64Url } from "../utils/crypto.ts"; +import { generateRandomString, sha256Base64Url } from "../utils/crypto.ts"; import { assertScimTrustedDeviceApprovalPolicy } from "./scimPolicy.ts"; const DEVICE_APPROVAL_STATUSES = new Set(["pending", "approved", "consumed", "denied"]); @@ -318,7 +318,7 @@ function validateCiphertext(value: Buffer, name: string) { } function generateVerificationCode() { - const value = generateRandomBytes(4).readUInt32BE(0) % 1_000_000; + const value = randomInt(0, 1_000_000); return value.toString().padStart(6, "0"); } diff --git a/packages/api/src/services/branding.test.ts b/packages/api/src/services/branding.test.ts new file mode 100644 index 00000000..eabc2aac --- /dev/null +++ b/packages/api/src/services/branding.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { validateImageBase64 } from "./branding.ts"; + +test("validateImageBase64 rejects svg branding images", () => { + const svg = Buffer.from("").toString("base64"); + + assert.throws(() => validateImageBase64(svg, "image/svg+xml"), /Invalid image type/); +}); + +test("validateImageBase64 keeps supported raster branding images", () => { + const data = Buffer.from("image-bytes").toString("base64"); + + assert.doesNotThrow(() => validateImageBase64(data, "image/png")); +}); diff --git a/packages/api/src/services/branding.ts b/packages/api/src/services/branding.ts index 817bc347..30b43e05 100644 --- a/packages/api/src/services/branding.ts +++ b/packages/api/src/services/branding.ts @@ -80,22 +80,12 @@ export function sanitizeCSS(css: string): string { return sanitized; } -export function sanitizeSvg(svg: string): string { - let s = svg; - s = s.replace(//gi, ""); - s = s.replace(/on[a-z]+\s*=\s*"[^"]*"/gi, ""); - s = s.replace(/on[a-z]+\s*=\s*'[^']*'/gi, ""); - s = s.replace(/xlink:href\s*=\s*"javascript:[^"]*"/gi, ""); - s = s.replace(/xlink:href\s*=\s*'javascript:[^']*'/gi, ""); - return s; -} - export function validateImageBase64(data: string, mimeType: string): void { const buffer = Buffer.from(data, "base64"); if (buffer.length > 2 * 1024 * 1024) { throw new Error("Image too large (max 2MB)"); } - const allowed = ["image/png", "image/jpeg", "image/svg+xml", "image/x-icon"]; + const allowed = ["image/png", "image/jpeg", "image/x-icon"]; if (!allowed.includes(mimeType)) { throw new Error("Invalid image type"); } diff --git a/packages/demo-app/server/src/createServer.test.ts b/packages/demo-app/server/src/createServer.test.ts new file mode 100644 index 00000000..b59ac59b --- /dev/null +++ b/packages/demo-app/server/src/createServer.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; +import { after, test } from "node:test"; +import { createServer, isDemoCorsOriginAllowed } from "./createServer.ts"; +import type { Context } from "./types.ts"; + +const serverApplication = createServer({ + db: null as never, + config: { port: 0, issuer: "http://localhost:9080" }, + logger: { info() {}, error() {} }, +} satisfies Context); + +after(async () => { + await new Promise((resolve, reject) => + serverApplication.server.close((error) => (error ? reject(error) : resolve())) + ); +}); + +test("demo CORS origin policy allows only loopback demo origins", () => { + assert.equal(isDemoCorsOriginAllowed("http://localhost:9092"), true); + assert.equal(isDemoCorsOriginAllowed("http://127.0.0.1:9092"), true); + assert.equal(isDemoCorsOriginAllowed("http://[::1]:9092"), true); + assert.equal(isDemoCorsOriginAllowed("https://evil.example"), false); + assert.equal(isDemoCorsOriginAllowed("http://localhost.evil.example"), false); + assert.equal(isDemoCorsOriginAllowed("null"), false); +}); + +test("demo CORS does not reflect credentialed headers for non-loopback origins", async () => { + await new Promise((resolve) => serverApplication.server.listen(0, "127.0.0.1", resolve)); + const address = serverApplication.server.address() as AddressInfo; + const baseUrl = `http://127.0.0.1:${address.port}`; + + const allowed = await fetch(`${baseUrl}/demo/health`, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:9092", + "Access-Control-Request-Method": "GET", + }, + }); + assert.equal(allowed.status, 204); + assert.equal(allowed.headers.get("access-control-allow-origin"), "http://localhost:9092"); + assert.equal(allowed.headers.get("access-control-allow-credentials"), null); + + const rejected = await fetch(`${baseUrl}/demo/health`, { + method: "OPTIONS", + headers: { + Origin: "https://evil.example", + "Access-Control-Request-Method": "GET", + }, + }); + assert.equal(rejected.status, 403); + assert.equal(rejected.headers.get("access-control-allow-origin"), null); + assert.equal(rejected.headers.get("access-control-allow-credentials"), null); +}); diff --git a/packages/demo-app/server/src/createServer.ts b/packages/demo-app/server/src/createServer.ts index 15aeb6ae..2644747c 100644 --- a/packages/demo-app/server/src/createServer.ts +++ b/packages/demo-app/server/src/createServer.ts @@ -3,9 +3,29 @@ import type { Context, Route } from "./types.ts"; import { initDemoSchema } from "./models/notes.ts"; import { getRoutes } from "./controllers/routes.ts"; +export function isDemoCorsOriginAllowed(origin: string) { + try { + const url = new URL(origin); + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) return false; + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + } catch { + return false; + } +} + function sendCorsHeaders(request: http.IncomingMessage, response: http.ServerResponse) { const origin = request.headers.origin as string | undefined; if (!origin) return false; + if (!isDemoCorsOriginAllowed(origin)) { + if (request.method === "OPTIONS") { + response.statusCode = 403; + response.end(); + return true; + } + return false; + } response.setHeader("Access-Control-Allow-Origin", origin); response.setHeader("Vary", "Origin"); response.setHeader( @@ -16,7 +36,6 @@ function sendCorsHeaders(request: http.IncomingMessage, response: http.ServerRes "Access-Control-Allow-Methods", request.headers["access-control-request-method"] || "GET,POST,PUT,DELETE,OPTIONS" ); - response.setHeader("Access-Control-Allow-Credentials", "true"); if (request.method === "OPTIONS") { response.statusCode = 204; response.end(); diff --git a/packages/demo-app/src/utils/noteContent.ts b/packages/demo-app/src/utils/noteContent.ts index 775eb73f..7501c2ea 100644 --- a/packages/demo-app/src/utils/noteContent.ts +++ b/packages/demo-app/src/utils/noteContent.ts @@ -8,6 +8,31 @@ function normalizeTag(value: string): string { return value.trim().toLowerCase(); } +const htmlEntities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => htmlEntities[character] || character); +} + +function getTextContent(value: string): string { + if (!value) return ""; + if (typeof DOMParser === "undefined") return escapeHtml(value); + return new DOMParser().parseFromString(value, "text/html").body.textContent || ""; +} + +function removeMarkdownHeadingPrefix(value: string): string { + if (!value.startsWith("#")) return value; + const next = value[1]; + if (next !== " " && next !== "\t") return value; + return value.slice(2); +} + export function normalizeTags(values: string[]): string[] { const unique = new Set(); @@ -52,7 +77,7 @@ export function parseDecryptedNoteContent(decryptedContent: string): ParsedNoteC } catch {} const lines = decryptedContent.split("\n"); - const title = lines[0]?.replace(/^#\s+/, "").replace(/<[^>]*>/g, "") || "Untitled"; + const title = getTextContent(removeMarkdownHeadingPrefix(lines[0] || "")).trim() || "Untitled"; const content = lines.slice(1).join("\n"); return { title, @@ -62,9 +87,5 @@ export function parseDecryptedNoteContent(decryptedContent: string): ParsedNoteC } export function getPreviewFromNoteContent(content: string): string { - return content - .replace(/<[^>]*>/g, "") - .replace(/\s+/g, " ") - .trim() - .substring(0, 150); + return getTextContent(content).split(/\s+/).join(" ").trim().substring(0, 150); } diff --git a/packages/demo-app/tests/noteContent.test.ts b/packages/demo-app/tests/noteContent.test.ts new file mode 100644 index 00000000..71dc20d2 --- /dev/null +++ b/packages/demo-app/tests/noteContent.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { getPreviewFromNoteContent, parseDecryptedNoteContent } from "../src/utils/noteContent.ts"; + +test("parseDecryptedNoteContent preserves structured JSON as plain values", () => { + const parsed = parseDecryptedNoteContent( + JSON.stringify({ title: "Title", content: "

Hello

", tags: [" Work ", "work"] }) + ); + + assert.deepEqual(parsed, { + title: "Title", + content: "

Hello

", + tags: ["work"], + }); +}); + +test("parseDecryptedNoteContent escapes fallback titles instead of stripping tags", () => { + const parsed = parseDecryptedNoteContent( + `# \nBody` + ); + + assert.equal( + parsed.title, + "<ScRiPt>alert(1)</ScRiPt><img src=x onerror=alert(2)>" + ); + assert.equal(parsed.content, "Body"); +}); + +test("parseDecryptedNoteContent escapes malformed fallback title tags", () => { + const parsed = parseDecryptedNoteContent(`# `); + + assert.equal(preview, "javajavascript:script:<ScRiPt>alert(1)</ScRiPt>"); +}); diff --git a/packages/test-suite/fixtures/testData.ts b/packages/test-suite/fixtures/testData.ts index 7d8605f1..b00174fa 100644 --- a/packages/test-suite/fixtures/testData.ts +++ b/packages/test-suite/fixtures/testData.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + export interface TestUser { username: string; email: string; @@ -12,7 +14,7 @@ export interface TestAdmin { } function randomId(): string { - return Math.random().toString(36).slice(2, 8); + return randomBytes(4).toString('hex'); } export function createTestAdmin(overrides: Partial = {}): TestAdmin { diff --git a/packages/test-suite/tests/admin/branding/branding.spec.ts b/packages/test-suite/tests/admin/branding/branding.spec.ts index 5810b0c4..e9fd09e8 100644 --- a/packages/test-suite/tests/admin/branding/branding.spec.ts +++ b/packages/test-suite/tests/admin/branding/branding.spec.ts @@ -195,20 +195,26 @@ test.describe('Admin - Branding Settings', () => { test('can upload and save a large logo without corrupting base64 data', async ({ page }) => { await page.waitForSelector('text="Brand Color"', { timeout: 10000 }); - const svg = `${'x'.repeat(200_000)}`; - const expectedLogo = Buffer.from(svg).toString('base64'); + const png = Buffer.concat([ + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mM88R8AAtUB6C8zHs0AAAAASUVORK5CYII=', + 'base64' + ), + Buffer.alloc(200_000, 0), + ]); + const expectedLogo = png.toString('base64'); await page.locator('input[type="file"]').first().setInputFiles({ - name: 'large-logo.svg', - mimeType: 'image/svg+xml', - buffer: Buffer.from(svg) + name: 'large-logo.png', + mimeType: 'image/png', + buffer: png, }); const logoPreview = page.getByAltText('Logo preview'); await expect(logoPreview).toBeVisible(); await expect .poll(async () => await logoPreview.getAttribute('src'), { timeout: 5000 }) - .toBe(`data:image/svg+xml;base64,${expectedLogo}`); + .toBe(`data:image/png;base64,${expectedLogo}`); await page.locator('button:has-text("Save")').first().click(); await expect(page.getByText('Branding saved').first()).toBeVisible({ timeout: 5000 }); @@ -216,7 +222,7 @@ test.describe('Admin - Branding Settings', () => { await page.locator('button:has-text("Reload")').first().click(); await expect(page.getByAltText('Logo preview')).toHaveAttribute( 'src', - `data:image/svg+xml;base64,${expectedLogo}` + `data:image/png;base64,${expectedLogo}` ); }); }); diff --git a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts index c54b3ea7..78f93ab5 100644 --- a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts +++ b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { expect, test, type Locator, type Page } from '@playwright/test'; import { createAccountKey, createKeyEnvelope } from '@DarkAuth/api/src/models/keybag.ts'; import { createTrustedDevice } from '@DarkAuth/api/src/models/trustedDevices.ts'; @@ -12,7 +13,7 @@ const adminCred = { }; function uniqueId(prefix: string) { - return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}-${Date.now()}-${randomUUID()}`; } function escapeXPathText(value: string) {