Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches:
- main

permissions:
contents: read

concurrency:
group: deploy-sites
cancel-in-progress: true
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: PR Checks
on:
pull_request:

permissions:
contents: read

concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
- main
pull_request:

permissions:
contents: read

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
Expand Down
12 changes: 9 additions & 3 deletions packages/admin-ui/src/pages/Branding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down Expand Up @@ -512,7 +518,7 @@ export default function Branding() {
<Label>Logo</Label>
<div style={{ display: "flex", gap: 8, marginTop: 8, alignItems: "center" }}>
<FileInput
accept="image/png,image/jpeg,image/svg+xml,image/x-icon"
accept={brandingImageAccept}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onPickImage(f, setLogo);
Expand Down Expand Up @@ -551,7 +557,7 @@ export default function Branding() {
<Label>Favicon</Label>
<div style={{ display: "flex", gap: 8, marginTop: 8, alignItems: "center" }}>
<FileInput
accept="image/png,image/jpeg,image/svg+xml,image/x-icon"
accept={brandingImageAccept}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onPickImage(f, setFavicon);
Expand Down
30 changes: 30 additions & 0 deletions packages/api/src/controllers/admin/settingsUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
40 changes: 37 additions & 3 deletions packages/api/src/controllers/user/usersDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -63,17 +64,50 @@ function hasMatchingAudience(payload: Record<string, unknown>, 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<UsersReadPermission> {
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<string, unknown>;

const mode = resolveUsersReadModeFromPayload(payload);
Expand Down
15 changes: 15 additions & 0 deletions packages/api/src/controllers/usersDirectory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from "node:test";
import {
hasRequiredPermission,
hasRequiredScope,
parseBearerToken,
resolveUsersReadModeFromPayload,
} from "./user/usersDirectory.ts";

Expand Down Expand Up @@ -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);
});
40 changes: 39 additions & 1 deletion packages/api/src/models/federation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});
71 changes: 62 additions & 9 deletions packages/api/src/models/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
);
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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`);
}
Expand Down
Loading
Loading