From de5361fe5c09d079060230707b615082c81886f3 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:58:02 -0400 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=94=92=20security(http):=20block=20?= =?UTF-8?q?redirect=20and=20DNS-rebinding=20SSRF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/triggers/providers/http/Http.test.ts | 63 ++++++++++++++++++++ app/triggers/providers/http/Http.ts | 75 +++++++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/app/triggers/providers/http/Http.test.ts b/app/triggers/providers/http/Http.test.ts index d5973c05f..c3d1e997b 100644 --- a/app/triggers/providers/http/Http.test.ts +++ b/app/triggers/providers/http/Http.test.ts @@ -205,6 +205,8 @@ describe('HTTP Trigger', () => { method: 'POST', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), data: container, }); }); @@ -222,6 +224,8 @@ describe('HTTP Trigger', () => { method: 'POST', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), data: containers, }); }); @@ -240,6 +244,8 @@ describe('HTTP Trigger', () => { method: 'GET', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), params: container, }); }); @@ -258,6 +264,8 @@ describe('HTTP Trigger', () => { method: 'POST', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), data: container, auth: { username: 'user', password: 'pass' }, }); @@ -312,6 +320,8 @@ describe('HTTP Trigger', () => { method: 'POST', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), data: container, headers: { Authorization: 'Bearer token' }, }); @@ -371,6 +381,8 @@ describe('HTTP Trigger', () => { method: 'POST', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), data: container, }); }); @@ -402,6 +414,8 @@ describe('HTTP Trigger', () => { method: 'PUT', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), }); }); @@ -419,6 +433,8 @@ describe('HTTP Trigger', () => { method: 'POST', url: 'https://example.com/webhook', timeout: 30000, + maxRedirects: 0, + lookup: expect.any(Function), data: container, proxy: { host: 'proxy', port: 8080 }, }); @@ -798,4 +814,51 @@ describe('HTTP Trigger SSRF guard', () => { const result = http.validateConfiguration({ url: 'http://example.com/webhook' }); expect(result.allowmetadata).toBe(false); }); + + test('disables redirects so a validated URL cannot redirect to metadata', async () => { + const { default: axios } = await import('axios'); + axios.mockResolvedValue({ data: {} }); + await http.register('trigger', 'http', 'test', { + url: 'https://example.com/webhook', + }); + + await http.trigger({ name: 'test' }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + maxRedirects: 0, + }), + ); + }); + + test('revalidates and pins the DNS address used by the outbound socket', async () => { + const { default: axios } = await import('axios'); + axios.mockResolvedValue({ data: {} }); + let lookupCount = 0; + dnsMockControl.lookupImpl = async () => { + lookupCount += 1; + return lookupCount === 1 + ? [{ address: '203.0.113.10', family: 4 }] + : [{ address: '169.254.169.254', family: 4 }]; + }; + await http.register('trigger', 'http', 'test', { + url: 'https://rebind.example/webhook', + }); + + await http.trigger({ name: 'test' }); + + const requestOptions = axios.mock.calls[0][0]; + await expect( + new Promise((resolve, reject) => { + requestOptions.lookup('rebind.example', {}, (error, address, family) => { + if (error) { + reject(error); + return; + } + resolve({ address, family }); + }); + }), + ).rejects.toThrow(/metadata.*address/i); + expect(lookupCount).toBe(2); + }); }); diff --git a/app/triggers/providers/http/Http.ts b/app/triggers/providers/http/Http.ts index 5280cb5c8..55531fc2d 100644 --- a/app/triggers/providers/http/Http.ts +++ b/app/triggers/providers/http/Http.ts @@ -1,5 +1,5 @@ import { lookup as dnsLookup } from 'node:dns/promises'; -import axios, { type AxiosRequestConfig } from 'axios'; +import axios, { type AddressFamily, type AxiosRequestConfig, type LookupAddress } from 'axios'; import { getOutboundHttpTimeoutMs } from '../../../configuration/runtime-defaults.js'; import { failClosedAuth, @@ -16,6 +16,16 @@ interface HttpRequestOptions extends Omit { }; } +type MetadataSafeLookup = ( + hostname: string, + options: object, + callback: ( + error: Error | null, + address: LookupAddress | LookupAddress[], + family?: AddressFamily, + ) => void, +) => void; + /** * Check whether an IP address falls in a cloud metadata / link-local range * that should be blocked by the SSRF guard. @@ -127,6 +137,60 @@ async function guardAgainstMetadataAddress(url: string, allowmetadata: boolean): } } +function createMetadataSafeLookup(allowmetadata: boolean): MetadataSafeLookup | undefined { + if (allowmetadata) { + return undefined; + } + + return (hostname, options, callback) => { + const requestedFamily = (options as { family?: unknown }).family; + const family = requestedFamily === 4 || requestedFamily === 6 ? requestedFamily : undefined; + + void dnsLookup(hostname, { all: true, ...(family === undefined ? {} : { family }) }).then( + (records) => { + const blockedRecord = records.find((record) => isMetadataAddress(record.address)); + if (blockedRecord) { + callback( + new Error( + `HTTP trigger blocked: "${hostname}" resolves to metadata/link-local address "${blockedRecord.address}". Set allowmetadata=true to override.`, + ), + '', + ); + return; + } + + const firstRecord = records[0]; + if (!firstRecord) { + callback( + new Error(`HTTP trigger DNS lookup returned no addresses for "${hostname}"`), + '', + ); + return; + } + + if ((options as { all?: unknown }).all === true) { + callback( + null, + records.map((record) => ({ + address: record.address, + family: record.family as Exclude, + })), + ); + return; + } + callback( + null, + firstRecord.address, + firstRecord.family as Exclude, + ); + }, + (error: unknown) => { + callback(error instanceof Error ? error : new Error(String(error)), ''); + }, + ); + }; +} + const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']); interface HttpConfiguration extends TriggerConfiguration { @@ -251,7 +315,16 @@ class Http extends Trigger { method: this.configuration.method, url: this.configuration.url, timeout: getOutboundHttpTimeoutMs(), + // Redirects must be explicit in trigger configuration so every destination + // is validated instead of allowing a safe URL to bounce into metadata. + maxRedirects: 0, }; + const safeLookup = createMetadataSafeLookup(this.configuration.allowmetadata ?? false); + if (safeLookup) { + // The validated DNS result is returned directly to the socket, closing the + // gap between the preflight resolution and the address actually contacted. + options.lookup = safeLookup; + } if (this.configuration.method === 'POST') { options.data = body; } else if (this.configuration.method === 'GET') { From 89edebf83eb6b8ed928a78e9c3078e085a1652c0 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:59:09 -0400 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=94=92=20security(ws):=20validate?= =?UTF-8?q?=20complete=20websocket=20origins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/ws-upgrade-utils.test.ts | 22 ++++++++++++++++ app/api/ws-upgrade-utils.ts | 44 ++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/app/api/ws-upgrade-utils.test.ts b/app/api/ws-upgrade-utils.test.ts index 9fb964266..d21de1a68 100644 --- a/app/api/ws-upgrade-utils.test.ts +++ b/app/api/ws-upgrade-utils.test.ts @@ -77,6 +77,28 @@ describe('ws-upgrade-utils', () => { expect(isOriginAllowed(request)).toBe(true); }); + test('rejects an http Origin for a TLS WebSocket on the same host', () => { + const request = { + headers: { origin: 'http://drydock.example.com', host: 'drydock.example.com' }, + socket: { encrypted: true }, + } as any; + expect(isOriginAllowed(request)).toBe(false); + }); + + test('uses X-Forwarded-Proto only when trust proxy is enabled', () => { + const request = { + headers: { + origin: 'https://drydock.example.com', + host: 'drydock.example.com', + 'x-forwarded-proto': 'https', + }, + socket: { encrypted: false }, + } as any; + + expect(isOriginAllowed(request, { trustproxy: false })).toBe(false); + expect(isOriginAllowed(request, { trustproxy: 1 })).toBe(true); + }); + test('rejects when Origin host does not match Host header', () => { const request = { headers: { origin: 'https://evil.com', host: 'localhost:3000' } } as any; expect(isOriginAllowed(request)).toBe(false); diff --git a/app/api/ws-upgrade-utils.ts b/app/api/ws-upgrade-utils.ts index 02863a2e5..d463eb6fa 100644 --- a/app/api/ws-upgrade-utils.ts +++ b/app/api/ws-upgrade-utils.ts @@ -52,17 +52,24 @@ function getFirstForwardedValue(value: unknown): string | undefined { return firstValue || undefined; } +function getSocketOriginProtocol(request: IncomingMessage): 'http:' | 'https:' | undefined { + const socket = request.socket as (Socket & { encrypted?: boolean }) | undefined; + if (!socket) { + return undefined; + } + return socket.encrypted === true ? 'https:' : 'http:'; +} + /** - * Validates the Origin header against the effective Host to prevent WebSocket CSRF. + * Validates the Origin header against the effective origin to prevent WebSocket CSRF. * Browsers always send an Origin header on WebSocket upgrade requests, so a * browser request with a mismatched Origin indicates a cross-site connection * attempt. Non-browser clients (CLI tools, agents) typically omit Origin * entirely, which is allowed. * - * When serverConfiguration has trust proxy enabled, the effective host is taken - * from the first value of X-Forwarded-Host (if present), falling back to the raw - * Host header. When trust proxy is disabled the X-Forwarded-Host header is - * ignored entirely so a direct client cannot forge it to bypass the check. + * When serverConfiguration has trust proxy enabled, the effective host and + * protocol can come from the first X-Forwarded-Host / X-Forwarded-Proto values. + * Otherwise forwarded headers are ignored and the socket transport is used. */ export function isOriginAllowed( request: IncomingMessage, @@ -82,14 +89,35 @@ export function isOriginAllowed( return false; } - let originHost: string; + const forwardedProtocol = trustProxy + ? getFirstForwardedValue(request.headers['x-forwarded-proto']) + : undefined; + let effectiveProtocol: 'http:' | 'https:' | undefined; + if (forwardedProtocol !== undefined) { + const normalizedProtocol = `${forwardedProtocol.toLowerCase()}:`; + if (normalizedProtocol !== 'http:' && normalizedProtocol !== 'https:') { + return false; + } + effectiveProtocol = normalizedProtocol; + } else { + effectiveProtocol = getSocketOriginProtocol(request); + } + + let parsedOrigin: URL; try { - originHost = new URL(origin).host; + parsedOrigin = new URL(origin); } catch { return false; } - return originHost === effectiveHost; + if (parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') { + return false; + } + + return ( + parsedOrigin.host === effectiveHost && + (effectiveProtocol === undefined || parsedOrigin.protocol === effectiveProtocol) + ); } export function writeUpgradeError(socket: Socket, statusCode: number, message: string): void { From 4512bcfd376461115e65bd77a1ddfc168482cfd1 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:01:08 -0400 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=94=92=20security(auth):=20require?= =?UTF-8?q?=20explicit=20anonymous=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/anonymous/Anonymous.test.ts | 37 +++++++++++-------- .../providers/anonymous/Anonymous.ts | 11 ++---- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/app/authentications/providers/anonymous/Anonymous.test.ts b/app/authentications/providers/anonymous/Anonymous.test.ts index a33912811..99fff7527 100644 --- a/app/authentications/providers/anonymous/Anonymous.test.ts +++ b/app/authentications/providers/anonymous/Anonymous.test.ts @@ -103,29 +103,34 @@ describe('Anonymous Authentication', () => { mockIsUpgrade.mockReturnValue(true); }); - test('should not throw during initAuthentication without confirmation', () => { - expect(() => anonymous.initAuthentication()).not.toThrow(); + test('should fail closed during initAuthentication without confirmation', () => { + expect(() => anonymous.initAuthentication()).toThrow( + 'No authentication configured during an upgrade', + ); }); - test('should log warning during initAuthentication without confirmation', () => { - anonymous.initAuthentication(); - expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining('No authentication configured'), + test('should require the explicit confirmation variable during an upgrade', () => { + expect(() => anonymous.initAuthentication()).toThrow(/DD_ANONYMOUS_AUTH_CONFIRM=true/); + }); + + test('should fail closed from getStrategy without confirmation', () => { + expect(() => anonymous.getStrategy()).toThrow( + 'Anonymous authentication cannot be enabled during an upgrade', ); }); - test('should return anonymous strategy without confirmation', () => { - const strategy = anonymous.getStrategy(); - expect(strategy).toBeDefined(); - expect(strategy.name).toBe('anonymous'); + test('should not downgrade the missing confirmation to a warning', () => { + expect(() => anonymous.getStrategy()).toThrow(); + expect(log.warn).not.toHaveBeenCalled(); }); - test('should log warning from getStrategy without confirmation', () => { - anonymous.getStrategy(); - expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining( - 'Anonymous authentication is enabled without explicit confirmation', - ), + test('should support the confirmation alias during an upgrade', () => { + process.env.DD_AUTH_ANONYMOUS_CONFIRM = 'true'; + expect(() => anonymous.initAuthentication()).not.toThrow(); + expect(anonymous.getStrategy()).toEqual( + expect.objectContaining({ + name: 'anonymous', + }), ); }); diff --git a/app/authentications/providers/anonymous/Anonymous.ts b/app/authentications/providers/anonymous/Anonymous.ts index d03ffa40a..dd25e291e 100644 --- a/app/authentications/providers/anonymous/Anonymous.ts +++ b/app/authentications/providers/anonymous/Anonymous.ts @@ -1,5 +1,4 @@ import { Strategy as AnonymousStrategy } from 'passport-anonymous'; -import log from '../../../log/index.js'; import { isUpgrade } from '../../../store/app.js'; import Authentication from '../Authentication.js'; @@ -18,10 +17,9 @@ class Anonymous extends Authentication { return; } if (isUpgrade()) { - log.warn( - 'No authentication configured — the dashboard is accessible without login. Set DD_AUTH_BASIC__USER / DD_AUTH_BASIC__HASH to secure it, or set DD_ANONYMOUS_AUTH_CONFIRM=true to silence this warning.', + throw new Error( + 'No authentication configured during an upgrade. Set DD_AUTH_BASIC__USER / DD_AUTH_BASIC__HASH to secure the dashboard, or set DD_ANONYMOUS_AUTH_CONFIRM=true to explicitly allow anonymous access.', ); - return; } throw new Error( 'No authentication configured and this is a fresh install. Set DD_AUTH_BASIC__USER / DD_AUTH_BASIC__HASH to secure the dashboard, or set DD_ANONYMOUS_AUTH_CONFIRM=true to allow anonymous access.', @@ -36,10 +34,9 @@ class Anonymous extends Authentication { return new AnonymousStrategy(); } if (isUpgrade()) { - log.warn( - 'Anonymous authentication is enabled without explicit confirmation; consider configuring authentication', + throw new Error( + 'Anonymous authentication cannot be enabled during an upgrade without DD_ANONYMOUS_AUTH_CONFIRM=true', ); - return new AnonymousStrategy(); } throw new Error( 'Anonymous authentication cannot be enabled on a fresh install without DD_ANONYMOUS_AUTH_CONFIRM=true', From 2debdbecb87ec93129b878605a59b98d084ca7df Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:02:52 -0400 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=94=92=20security(store):=20restric?= =?UTF-8?q?t=20persistent=20data=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docker.entrypoint.sh | 1 + Dockerfile | 4 +-- app/configuration/dockerfile-defaults.test.ts | 11 +++++++ app/store/index.test.ts | 20 +++++++++++-- app/store/index.ts | 29 +++++++++++++++++-- 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/Docker.entrypoint.sh b/Docker.entrypoint.sh index 0eb33656d..c5b5eefd8 100755 --- a/Docker.entrypoint.sh +++ b/Docker.entrypoint.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -eo pipefail +umask 077 require_insecure_root_ack() { if [ "${DD_ALLOW_INSECURE_ROOT}" != "true" ]; then diff --git a/Dockerfile b/Dockerfile index ca8f123f2..4c803dde3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN apk add --no-cache \ tzdata=2026c-r0 \ && apk add --no-cache cosign=3.0.6-r1 \ && apk upgrade --no-cache zlib libcrypto3 libssl3 libexpat \ - && mkdir /store && chown node:node /store + && mkdir -m 0700 /store && chown node:node /store # Build stage for healthcheck binary (~65KB static binary) FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS healthcheck-build @@ -107,4 +107,4 @@ COPY --from=ui-build /home/node/ui/dist/ ./ui # DD_VERSION is the only per-build-tag layer — keep it last so every # layer above remains cache-hittable across rc.N → rc.N+1 builds. ARG DD_VERSION=unknown -ENV DD_VERSION=$DD_VERSION \ No newline at end of file +ENV DD_VERSION=$DD_VERSION diff --git a/app/configuration/dockerfile-defaults.test.ts b/app/configuration/dockerfile-defaults.test.ts index 96e365fe3..0f34c1d47 100644 --- a/app/configuration/dockerfile-defaults.test.ts +++ b/app/configuration/dockerfile-defaults.test.ts @@ -24,4 +24,15 @@ describe('Dockerfile release defaults', () => { expect(dockerfile).toContain('tzdata=2026c-r0'); expect(dockerfile).not.toContain('tzdata=2026b-r0'); }); + + test('release image creates the persistent store with owner-only permissions', () => { + const dockerfile = fs.readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); + const entrypoint = fs.readFileSync( + new URL('../../Docker.entrypoint.sh', import.meta.url), + 'utf8', + ); + + expect(dockerfile).toContain('mkdir -m 0700 /store'); + expect(entrypoint).toMatch(/^umask 077$/mu); + }); }); diff --git a/app/store/index.test.ts b/app/store/index.test.ts index 342402294..312f26d5f 100644 --- a/app/store/index.test.ts +++ b/app/store/index.test.ts @@ -33,7 +33,12 @@ const { function createFsMock(overrides = {}) { return { - default: { existsSync: vi.fn(), mkdirSync: vi.fn(), ...overrides }, + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), + ...overrides, + }, }; } @@ -148,10 +153,16 @@ vi.mock('./update-operation', createCollectionsMock); vi.mock('../log', createLogMock); describe('Store Module', () => { + const originalUmask = process.umask(); + beforeEach(async () => { vi.clearAllMocks(); }); + afterAll(() => { + process.umask(originalUmask); + }); + test('should initialize store successfully', async () => { fs.existsSync.mockReturnValue(true); @@ -162,6 +173,9 @@ describe('Store Module', () => { autosave: true, autosaveInterval: 300000, }); + expect(process.umask()).toBe(0o077); + expect(fs.chmodSync).toHaveBeenCalledWith('/test/store', 0o700); + expect(fs.chmodSync).toHaveBeenCalledWith('/test/store/test.json', 0o600); const app = await import('./app.js'); const container = await import('./container.js'); @@ -187,7 +201,7 @@ describe('Store Module', () => { await store.init(); - expect(fs.mkdirSync).toHaveBeenCalledWith('/test/store'); + expect(fs.mkdirSync).toHaveBeenCalledWith('/test/store', { mode: 0o700 }); }); test('should return configuration', async () => { @@ -329,6 +343,8 @@ describe('Store Module', () => { const Loki = (await import('lokijs')).default; const dbInstance = Loki.mock.results[0].value; expect(dbInstance.saveDatabase).toHaveBeenCalledTimes(1); + const mockedFs = (await import('node:fs')).default; + expect(mockedFs.chmodSync).toHaveBeenLastCalledWith('/test/store/test.json', 0o600); }); test('should no-op save when store runs in memory mode', async () => { diff --git a/app/store/index.ts b/app/store/index.ts index ad6916c11..3efc6613c 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -43,6 +43,15 @@ type LokiDatabase = InstanceType; let db: LokiDatabase | undefined; let isMemoryMode = false; let storePathResolved: string | undefined; +const STORE_DIRECTORY_MODE = 0o700; +const STORE_FILE_MODE = 0o600; + +function enforceStorePermissions(storeDirectory: string, storePath: string): void { + fs.chmodSync(storeDirectory, STORE_DIRECTORY_MODE); + if (fs.existsSync(storePath)) { + fs.chmodSync(storePath, STORE_FILE_MODE); + } +} function createCollections() { agentKeys.createCollections(db); @@ -145,6 +154,12 @@ export async function init(options: { memory?: boolean } = {}) { throw new Error('DD_STORE_FILE must reference a file path, not a directory'); } + if (!isMemoryMode) { + // Loki saves through temporary files during both explicit and background autosaves. + // A restrictive process umask keeps every replacement file owner-readable only. + process.umask(0o077); + } + db = new Loki(storePath, { autosave: !isMemoryMode, autosaveInterval: 300000, @@ -167,8 +182,9 @@ export async function init(options: { memory?: boolean } = {}) { log.info(`Load store from (${storePath})`); if (!fs.existsSync(storeDirectory)) { log.info(`Create folder ${storeDirectory}`); - fs.mkdirSync(storeDirectory); + fs.mkdirSync(storeDirectory, { mode: STORE_DIRECTORY_MODE }); } + enforceStorePermissions(storeDirectory, storePath); return new Promise((resolve, reject) => { db.loadDatabase({}, (err) => { void loadDb(err, resolve, reject).catch(reject); @@ -194,7 +210,16 @@ export async function save() { if (err) { reject(err); } else { - resolve(); + try { + if (!storePathResolved) { + throw new Error('Persistent store path was not initialized'); + } + const storeDirectory = path.dirname(storePathResolved); + enforceStorePermissions(storeDirectory, storePathResolved); + resolve(); + } catch (permissionError) { + reject(permissionError); + } } }); }); From 9f69c3f1b5868d8822c644721c2a1ac1b781b840 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:04:06 -0400 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=94=92=20security(auth):=20use=20an?= =?UTF-8?q?=20application-specific=20session=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth.test.ts | 15 +++---- app/api/auth.ts | 2 + app/api/csrf.test.ts | 86 +++++++++++++++++++-------------------- app/api/openapi.test.ts | 6 ++- app/api/openapi/index.ts | 3 +- app/api/session-cookie.ts | 1 + 6 files changed, 61 insertions(+), 52 deletions(-) create mode 100644 app/api/session-cookie.ts diff --git a/app/api/auth.test.ts b/app/api/auth.test.ts index 4462f1d30..07ebcf336 100644 --- a/app/api/auth.test.ts +++ b/app/api/auth.test.ts @@ -1088,6 +1088,7 @@ describe('Auth Router', () => { auth.init(app); const sessionConfig = (session as unknown as ReturnType).mock.calls[0][0]; + expect(sessionConfig.name).toBe('drydock.sid'); expect(sessionConfig.cookie).toEqual( expect.objectContaining({ httpOnly: true, @@ -3610,7 +3611,7 @@ describe('Auth Router', () => { const req = { method: 'POST', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return 'cross-site'; return undefined; }), @@ -3631,7 +3632,7 @@ describe('Auth Router', () => { const req = { method: 'POST', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return undefined; if (header === 'origin') return 'https://attacker.example.com'; if (header === 'x-forwarded-proto') return undefined; @@ -3657,7 +3658,7 @@ describe('Auth Router', () => { const req = { method: 'POST', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return undefined; if (header === 'origin') return 'https://drydock.example.com'; if (header === 'x-forwarded-proto') return undefined; @@ -3682,7 +3683,7 @@ describe('Auth Router', () => { const req = { method: 'POST', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return 'same-origin'; if (header === 'origin') return 'https://drydock.example.com'; if (header === 'x-forwarded-proto') return undefined; @@ -3707,7 +3708,7 @@ describe('Auth Router', () => { const req = { method: 'POST', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return 'cross-site'; return undefined; }), @@ -3728,7 +3729,7 @@ describe('Auth Router', () => { const req = { method: 'POST', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return undefined; if (header === 'origin') return 'http://drydock.local'; if (header === 'x-forwarded-proto') return undefined; @@ -3753,7 +3754,7 @@ describe('Auth Router', () => { const req = { method: 'GET', get: vi.fn((header: string) => { - if (header === 'cookie') return 'connect.sid=abc'; + if (header === 'cookie') return 'drydock.sid=abc'; if (header === 'sec-fetch-site') return 'cross-site'; return undefined; }), diff --git a/app/api/auth.ts b/app/api/auth.ts index 41cee1eb1..e8e3cde8e 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -40,6 +40,7 @@ import { isIdentityAwareRateLimitKeyingEnabled, isRequestAuthenticated, } from './rate-limit-key.js'; +import { SESSION_COOKIE_NAME } from './session-cookie.js'; const LokiStore = ConnectLoki(session); const router = express.Router(); @@ -369,6 +370,7 @@ export function init(app: Application): void { // Init express session sessionMiddleware = session({ + name: SESSION_COOKIE_NAME, store: new LokiStore({ path: `${store.getConfiguration().path}/${store.getConfiguration().file}`, // Keep store retention >= longest auth cookie lifespan (remember-me). diff --git a/app/api/csrf.test.ts b/app/api/csrf.test.ts index 8c0000863..d661bdf95 100644 --- a/app/api/csrf.test.ts +++ b/app/api/csrf.test.ts @@ -56,7 +56,7 @@ describe('CSRF middleware', () => { method: 'GET', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', // no origin/referer — would be rejected if GET were treated as unsafe }, @@ -73,7 +73,7 @@ describe('CSRF middleware', () => { test('should skip CSRF validation for HEAD method', () => { const req = createReq({ method: 'HEAD', - headers: { cookie: 'connect.sid=s%3Atest' }, + headers: { cookie: 'drydock.sid=s%3Atest' }, }); const res = createRes(); const next = vi.fn(); @@ -87,7 +87,7 @@ describe('CSRF middleware', () => { test('should skip CSRF validation for OPTIONS method', () => { const req = createReq({ method: 'OPTIONS', - headers: { cookie: 'connect.sid=s%3Atest' }, + headers: { cookie: 'drydock.sid=s%3Atest' }, }); const res = createRes(); const next = vi.fn(); @@ -101,7 +101,7 @@ describe('CSRF middleware', () => { test('should skip CSRF validation for TRACE method', () => { const req = createReq({ method: 'TRACE', - headers: { cookie: 'connect.sid=s%3Atest' }, + headers: { cookie: 'drydock.sid=s%3Atest' }, }); const res = createRes(); const next = vi.fn(); @@ -117,7 +117,7 @@ describe('CSRF middleware', () => { method: 'DELETE', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -136,7 +136,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -155,7 +155,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'sec-fetch-site': 'cross-site', @@ -176,7 +176,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'sec-fetch-site': 'same-site', @@ -196,7 +196,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'sec-fetch-site': 'same-origin', @@ -216,7 +216,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'sec-fetch-site': 'CROSS-SITE', @@ -238,7 +238,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'sec-fetch-site': ' cross-site ', @@ -262,7 +262,7 @@ describe('CSRF middleware', () => { protocol: 'https', // what Express resolves from X-Forwarded-Proto when trust proxy is on trustProxy: true, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -282,7 +282,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https:', // unlikely but parseProtocol handles it headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -304,7 +304,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'HTTPS', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -324,7 +324,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'http:', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'http://drydock.example.com', }, @@ -345,7 +345,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'ftp', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'ftp://drydock.example.com', }, @@ -368,7 +368,7 @@ describe('CSRF middleware', () => { protocol: 'https', // Express-resolved from X-Forwarded-Proto trustProxy: true, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock:3000', origin: 'https://drydock.example.com', 'x-forwarded-host': 'drydock.example.com', @@ -390,7 +390,7 @@ describe('CSRF middleware', () => { protocol: 'https', trustProxy: true, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'x-forwarded-host': ' , , ', @@ -412,7 +412,7 @@ describe('CSRF middleware', () => { protocol: 'https', trustProxy: true, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'internal-host', origin: 'https://drydock.example.com', 'x-forwarded-host': 'drydock.example.com, other-proxy.example.com', @@ -435,7 +435,7 @@ describe('CSRF middleware', () => { protocol: 'https', trustProxy: true, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'internal-host', origin: 'https://drydock.example.com', 'x-forwarded-host': ', drydock.example.com', @@ -460,7 +460,7 @@ describe('CSRF middleware', () => { protocol: 'https', trustProxy: false, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', // forged header — should be ignored @@ -487,7 +487,7 @@ describe('CSRF middleware', () => { protocol: 'https', trustProxy: false, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://attacker.example.com', // forged header @@ -514,7 +514,7 @@ describe('CSRF middleware', () => { protocol: 'http', trustProxy: false, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'x-forwarded-proto': 'https', @@ -541,7 +541,7 @@ describe('CSRF middleware', () => { protocol: 'http', trustProxy: false, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -561,7 +561,7 @@ describe('CSRF middleware', () => { protocol: 'http', trustProxy: false, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', 'x-forwarded-proto': 'http', @@ -584,7 +584,7 @@ describe('CSRF middleware', () => { protocol: 'https', // Express-resolved trustProxy: true, headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'internal:8080', origin: 'https://drydock.example.com:8443', 'x-forwarded-host': 'drydock.example.com:8443', @@ -606,7 +606,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com:9000', origin: 'https://drydock.example.com:9000', }, @@ -626,7 +626,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com:9000', origin: 'https://drydock.example.com', }, @@ -648,7 +648,7 @@ describe('CSRF middleware', () => { method: 'PATCH', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', referer: 'https://drydock.example.com/settings', }, @@ -667,7 +667,7 @@ describe('CSRF middleware', () => { method: 'DELETE', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://attacker.example.com', }, @@ -687,7 +687,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', }, }); @@ -706,7 +706,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', origin: 'https://drydock.example.com', }, }); @@ -725,7 +725,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: ' ', origin: 'https://drydock.example.com', }, @@ -745,7 +745,7 @@ describe('CSRF middleware', () => { method: 'PUT', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'not-a-valid-origin', }, @@ -765,7 +765,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: '', }, @@ -785,7 +785,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: ' ', }, @@ -805,7 +805,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'ftp', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'ftp://drydock.example.com', }, @@ -865,7 +865,7 @@ describe('CSRF middleware', () => { method: null, protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -884,7 +884,7 @@ describe('CSRF middleware', () => { method: '', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -904,7 +904,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'ftp', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }, @@ -925,7 +925,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'https', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', // no origin, no referer }, @@ -950,7 +950,7 @@ describe('CSRF middleware', () => { app: { get: vi.fn(() => false) }, get: vi.fn((name: string) => { const headers: Record = { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', host: 'drydock.example.com', origin: 'https://drydock.example.com', }; @@ -975,7 +975,7 @@ describe('CSRF middleware', () => { method: 'POST', protocol: 'ftp', headers: { - cookie: 'connect.sid=s%3Atest', + cookie: 'drydock.sid=s%3Atest', // no host, no origin, no referer → both expectedOrigin and requestOrigin are undefined }, }); diff --git a/app/api/openapi.test.ts b/app/api/openapi.test.ts index ba4638793..4a0b621e2 100644 --- a/app/api/openapi.test.ts +++ b/app/api/openapi.test.ts @@ -57,7 +57,11 @@ describe('OpenAPI document', () => { }); test('should define session, webhook, registry webhook, and metrics security schemes', () => { - expect(openApiDocument.components.securitySchemes.sessionAuth).toBeDefined(); + expect(openApiDocument.components.securitySchemes.sessionAuth).toMatchObject({ + type: 'apiKey', + in: 'cookie', + name: 'drydock.sid', + }); expect(openApiDocument.components.securitySchemes.webhookBearerAuth).toBeDefined(); expect(openApiDocument.components.securitySchemes.registryWebhookSignature).toMatchObject({ type: 'apiKey', diff --git a/app/api/openapi/index.ts b/app/api/openapi/index.ts index 0acd1b422..b2a797eb5 100644 --- a/app/api/openapi/index.ts +++ b/app/api/openapi/index.ts @@ -1,4 +1,5 @@ import { getVersion } from '../../configuration/index.js'; +import { SESSION_COOKIE_NAME } from '../session-cookie.js'; import { openApiPaths } from './paths/index.js'; import { openApiSchemas } from './schemas.js'; @@ -53,7 +54,7 @@ export const openApiDocument = { sessionAuth: { type: 'apiKey', in: 'cookie', - name: 'connect.sid', + name: SESSION_COOKIE_NAME, description: 'Session cookie authentication. For unsafe methods, requests must also satisfy same-origin CSRF validation (Origin/Referer/Sec-Fetch-Site checks).', }, diff --git a/app/api/session-cookie.ts b/app/api/session-cookie.ts new file mode 100644 index 000000000..84e0ce50b --- /dev/null +++ b/app/api/session-cookie.ts @@ -0,0 +1 @@ +export const SESSION_COOKIE_NAME = 'drydock.sid'; From e23c555c1b73feb6cf32f1d266f7dba1475c5efa Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:05:31 -0400 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=94=92=20security(icons):=20pin=20r?= =?UTF-8?q?untime=20CDN=20revisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/icons.test.ts | 6 +++--- app/api/icons/fetch.test.ts | 2 +- app/api/icons/providers.test.ts | 14 ++++++++++++-- app/api/icons/providers.ts | 12 +++++++++--- app/triggers/providers/mqtt/Hass.test.ts | 18 ++++++++++-------- app/triggers/providers/mqtt/Hass.ts | 15 ++++++++------- 6 files changed, 43 insertions(+), 24 deletions(-) diff --git a/app/api/icons.test.ts b/app/api/icons.test.ts index 64f2049a8..e48c52aff 100644 --- a/app/api/icons.test.ts +++ b/app/api/icons.test.ts @@ -304,7 +304,7 @@ describe('Icons Router', () => { ); expect(mockAxiosGet).toHaveBeenCalledWith( - 'https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/docker.svg', + 'https://cdn.jsdelivr.net/npm/simple-icons@16.21.0/icons/docker.svg', { responseType: 'arraybuffer', timeout: 10000, @@ -345,7 +345,7 @@ describe('Icons Router', () => { ); expect(mockAxiosGet).toHaveBeenCalledWith( - 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/docker.png', + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@46b860c70e866212311aef2f98da3775c17f5068/png/docker.png', { responseType: 'arraybuffer', timeout: 10000, @@ -408,7 +408,7 @@ describe('Icons Router', () => { expect(mockUnlink).toHaveBeenCalledWith('/store/icons/simple/docker.svg'); expect(mockAxiosGet).toHaveBeenCalledWith( - 'https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/docker.svg', + 'https://cdn.jsdelivr.net/npm/simple-icons@16.21.0/icons/docker.svg', { responseType: 'arraybuffer', timeout: 10000, diff --git a/app/api/icons/fetch.test.ts b/app/api/icons/fetch.test.ts index 250671113..0e3dcee9c 100644 --- a/app/api/icons/fetch.test.ts +++ b/app/api/icons/fetch.test.ts @@ -70,7 +70,7 @@ describe('icons/fetch', () => { }); expect(mockAxiosGet).toHaveBeenCalledWith( - 'https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/docker.svg', + 'https://cdn.jsdelivr.net/npm/simple-icons@16.21.0/icons/docker.svg', { responseType: 'arraybuffer', timeout: 10000, diff --git a/app/api/icons/providers.test.ts b/app/api/icons/providers.test.ts index 4fbf5bc62..d32becb68 100644 --- a/app/api/icons/providers.test.ts +++ b/app/api/icons/providers.test.ts @@ -15,9 +15,19 @@ describe('icons/providers', () => { expect(BUNDLED_ICON_PROVIDERS.has('simple')).toBe(false); }); - test('builds expected upstream URL for simple icons', () => { + test('pins every runtime icon URL to an immutable upstream revision', () => { + expect(providers.homarr.url('docker')).toBe( + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@46b860c70e866212311aef2f98da3775c17f5068/png/docker.png', + ); + expect(providers.selfhst.url('docker')).toBe( + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/docker.png', + ); expect(providers.simple.url('docker')).toBe( - 'https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/docker.svg', + 'https://cdn.jsdelivr.net/npm/simple-icons@16.21.0/icons/docker.svg', + ); + + expect(Object.values(providers).map((provider) => provider.url('docker'))).not.toContainEqual( + expect.stringContaining('@latest'), ); }); }); diff --git a/app/api/icons/providers.ts b/app/api/icons/providers.ts index b37240a13..8c15eb9a6 100644 --- a/app/api/icons/providers.ts +++ b/app/api/icons/providers.ts @@ -1,18 +1,24 @@ +const HOMARR_ICONS_REVISION = '46b860c70e866212311aef2f98da3775c17f5068'; +const SELFHST_ICONS_REVISION = '47eb6b11d006d7708fad53f4893048c0d515117a'; +const SIMPLE_ICONS_VERSION = '16.21.0'; + const providers = { homarr: { extension: 'png', url: (slug: string) => - `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${slug}.png`, + `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@${HOMARR_ICONS_REVISION}/png/${slug}.png`, contentType: 'image/png', }, selfhst: { extension: 'png', - url: (slug: string) => `https://cdn.jsdelivr.net/gh/selfhst/icons/png/${slug}.png`, + url: (slug: string) => + `https://cdn.jsdelivr.net/gh/selfhst/icons@${SELFHST_ICONS_REVISION}/png/${slug}.png`, contentType: 'image/png', }, simple: { extension: 'svg', - url: (slug: string) => `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${slug}.svg`, + url: (slug: string) => + `https://cdn.jsdelivr.net/npm/simple-icons@${SIMPLE_ICONS_VERSION}/icons/${slug}.svg`, contentType: 'image/svg+xml', }, } as const; diff --git a/app/triggers/providers/mqtt/Hass.test.ts b/app/triggers/providers/mqtt/Hass.test.ts index 8aa8959db..9b47338bf 100644 --- a/app/triggers/providers/mqtt/Hass.test.ts +++ b/app/triggers/providers/mqtt/Hass.test.ts @@ -290,15 +290,17 @@ test('addContainerSensor must publish sensor discovery message expected by HA', test.each([ { displayIcon: 'sh:nextcloud', - expectedPicture: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/nextcloud.png', + expectedPicture: + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/nextcloud.png', }, { displayIcon: 'hl:nextcloud', - expectedPicture: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nextcloud.png', + expectedPicture: + 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@46b860c70e866212311aef2f98da3775c17f5068/png/nextcloud.png', }, { displayIcon: 'si:nextcloud', - expectedPicture: 'https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/nextcloud.svg', + expectedPicture: 'https://cdn.jsdelivr.net/npm/simple-icons@16.21.0/icons/nextcloud.svg', }, { displayIcon: 'sh: ', @@ -342,7 +344,7 @@ test('addContainerSensor should strip file extension from icon slug', async () = const discoveryCall = mqttClientMock.publish.mock.calls[0]; const discoveryPayload = JSON.parse(discoveryCall[1]); expect(discoveryPayload.entity_picture).toBe( - 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/nextcloud.png', + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/nextcloud.png', ); }); @@ -359,7 +361,7 @@ test('addContainerSensor should ignore empty dd.display.picture', async () => { const discoveryCall = mqttClientMock.publish.mock.calls[0]; const discoveryPayload = JSON.parse(discoveryCall[1]); expect(discoveryPayload.entity_picture).toBe( - 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/nextcloud.png', + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/nextcloud.png', ); }); @@ -376,7 +378,7 @@ test('addContainerSensor should ignore non-URL dd.display.picture', async () => const discoveryCall = mqttClientMock.publish.mock.calls[0]; const discoveryPayload = JSON.parse(discoveryCall[1]); expect(discoveryPayload.entity_picture).toBe( - 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/nextcloud.png', + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/nextcloud.png', ); }); @@ -408,7 +410,7 @@ test('addContainerSensor should ignore removed wud.display.picture', async () => const discoveryCall = mqttClientMock.publish.mock.calls[0]; const discoveryPayload = JSON.parse(discoveryCall[1]); expect(discoveryPayload.entity_picture).toBe( - 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/nextcloud.png', + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/nextcloud.png', ); }); @@ -426,7 +428,7 @@ test('addContainerSensor should not fall through to wud.display.picture for an e const discoveryCall = mqttClientMock.publish.mock.calls[0]; const discoveryPayload = JSON.parse(discoveryCall[1]); expect(discoveryPayload.entity_picture).toBe( - 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/nextcloud.png', + 'https://cdn.jsdelivr.net/gh/selfhst/icons@47eb6b11d006d7708fad53f4893048c0d515117a/png/nextcloud.png', ); }); diff --git a/app/triggers/providers/mqtt/Hass.ts b/app/triggers/providers/mqtt/Hass.ts index 90580312b..ae3a9deb7 100644 --- a/app/triggers/providers/mqtt/Hass.ts +++ b/app/triggers/providers/mqtt/Hass.ts @@ -1,5 +1,6 @@ import type { MqttClient } from 'mqtt'; import { recordAuditEvent } from '../../../api/audit-events.js'; +import { providers as iconProviders } from '../../../api/icons/providers.js'; import { getVersion } from '../../../configuration/index.js'; import { registerContainerAdded, @@ -184,15 +185,15 @@ function resolveEntityPicture(icon?: string): string { const provider = iconMatch[1].toLowerCase(); const rawSlug = iconMatch[2]; - const cdnMap: Record = { - sh: { ext: 'png', base: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png' }, - hl: { ext: 'png', base: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png' }, - si: { ext: 'svg', base: 'https://cdn.jsdelivr.net/npm/simple-icons@latest/icons' }, + const cdnMap = { + sh: iconProviders.selfhst, + hl: iconProviders.homarr, + si: iconProviders.simple, }; // Provider is guaranteed to be sh|hl|si by the regex above - const cdn = cdnMap[provider]; - const slug = normalizeIconSlug(rawSlug, cdn.ext); - return `${cdn.base}/${slug}.${cdn.ext}`; + const cdn = cdnMap[provider as keyof typeof cdnMap]; + const slug = normalizeIconSlug(rawSlug, cdn.extension); + return cdn.url(slug); } function resolveEntityPictureOverride(container: { From b5e904ef98defc0ad85fdd24b8740016826d9d37 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:06:54 -0400 Subject: [PATCH 07/15] =?UTF-8?q?=E2=9C=85=20test(security):=20allow=20pat?= =?UTF-8?q?ched=20dependency=20upgrades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/demo/tests/security/yaml-lockfile.test.js | 9 +++++++-- e2e/tests/security/brace-expansion-lockfile.test.js | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/demo/tests/security/yaml-lockfile.test.js b/apps/demo/tests/security/yaml-lockfile.test.js index 4f9fccfdc..f2d470614 100644 --- a/apps/demo/tests/security/yaml-lockfile.test.js +++ b/apps/demo/tests/security/yaml-lockfile.test.js @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import test from 'node:test'; +const MINIMUM_SAFE_YAML_VERSION = '2.8.3'; + function compareSemver(a, b) { const aParts = a.split('.').map(Number); const bParts = b.split('.').map(Number); @@ -21,15 +23,18 @@ function compareSemver(a, b) { test('package manifest explicitly pins yaml to the patched version', () => { const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')); + const pinnedVersion = packageJson.overrides?.yaml; - assert.equal(packageJson.overrides?.yaml, '2.8.3'); + assert.equal(typeof pinnedVersion, 'string'); + assert.match(pinnedVersion, /^\d+\.\d+\.\d+$/u); + assert.ok(compareSemver(pinnedVersion, MINIMUM_SAFE_YAML_VERSION) >= 0); }); test('package lockfile does not resolve vulnerable yaml versions', () => { const lockfile = JSON.parse(readFileSync(join(process.cwd(), 'package-lock.json'), 'utf8')); const vulnerableEntries = Object.entries(lockfile.packages ?? {}) .filter(([path, value]) => path === 'node_modules/yaml' && typeof value.version === 'string') - .filter(([, value]) => compareSemver(value.version, '2.8.3') < 0); + .filter(([, value]) => compareSemver(value.version, MINIMUM_SAFE_YAML_VERSION) < 0); assert.deepEqual(vulnerableEntries, []); }); diff --git a/e2e/tests/security/brace-expansion-lockfile.test.js b/e2e/tests/security/brace-expansion-lockfile.test.js index b13774872..f0c5371f7 100644 --- a/e2e/tests/security/brace-expansion-lockfile.test.js +++ b/e2e/tests/security/brace-expansion-lockfile.test.js @@ -3,6 +3,8 @@ const { readFileSync } = require('node:fs'); const { join } = require('node:path'); const test = require('node:test'); +const MINIMUM_SAFE_BRACE_EXPANSION_VERSION = '5.0.5'; + function compareSemver(a, b) { const aParts = a.split('.').map(Number); const bParts = b.split('.').map(Number); @@ -22,8 +24,11 @@ function compareSemver(a, b) { test('package manifest explicitly pins brace-expansion to the patched version', () => { const packageJsonPath = join(process.cwd(), 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const pinnedVersion = packageJson.overrides?.['brace-expansion']; - assert.equal(packageJson.overrides?.['brace-expansion'], '5.0.5'); + assert.equal(typeof pinnedVersion, 'string'); + assert.match(pinnedVersion, /^\d+\.\d+\.\d+$/u); + assert.ok(compareSemver(pinnedVersion, MINIMUM_SAFE_BRACE_EXPANSION_VERSION) >= 0); }); test('package lockfile does not resolve vulnerable brace-expansion versions', () => { @@ -33,7 +38,7 @@ test('package lockfile does not resolve vulnerable brace-expansion versions', () .filter( ([path, value]) => path.includes('brace-expansion') && typeof value.version === 'string', ) - .filter(([, value]) => compareSemver(value.version, '5.0.5') < 0); + .filter(([, value]) => compareSemver(value.version, MINIMUM_SAFE_BRACE_EXPANSION_VERSION) < 0); assert.deepEqual(vulnerableEntries, []); }); From 9b889ec3cf33db8d3202a59ff6f7bbc143158a65 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:12:20 -0400 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=94=92=20security(web):=20enforce?= =?UTF-8?q?=20a=20nonce-based=20script=20CSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/next.config.mjs | 2 +- .../scripts/content-security-policy.test.mjs | 74 +++++++++++++++++++ apps/web/scripts/next-config.test.mjs | 4 +- apps/web/src/app/compare/page.tsx | 6 +- apps/web/src/app/docs/[[...slug]]/page.tsx | 6 +- apps/web/src/app/layout.tsx | 9 ++- apps/web/src/app/page.tsx | 16 +--- .../trivy-supply-chain-march-2026/page.tsx | 6 +- apps/web/src/components/comparison-page.tsx | 6 +- apps/web/src/components/json-ld.tsx | 14 ++++ apps/web/src/lib/content-security-policy.mjs | 30 ++++++++ apps/web/src/proxy.ts | 34 +++++++++ apps/web/vercel.json | 4 - 13 files changed, 173 insertions(+), 38 deletions(-) create mode 100644 apps/web/scripts/content-security-policy.test.mjs create mode 100644 apps/web/src/components/json-ld.tsx create mode 100644 apps/web/src/lib/content-security-policy.mjs create mode 100644 apps/web/src/proxy.ts diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index f7a0e0e55..405c98a4b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -25,7 +25,7 @@ const nextConfig = { // Nothing hydrates: homepage reveal sections stay invisible, docs nav goes // dead. Open upstream bug: vercel/next.js#91633. Removed in #236, re-added by // mistake in v1.5.1-rc.1 (#454). Only safe to re-enable once #91633 ships a - // fix. The CSP in vercel.json is the real script hardening here. + // fix. The request-scoped nonce CSP in src/proxy.ts is the script hardening. images: { remotePatterns: [ { diff --git a/apps/web/scripts/content-security-policy.test.mjs b/apps/web/scripts/content-security-policy.test.mjs new file mode 100644 index 000000000..9cfdd0351 --- /dev/null +++ b/apps/web/scripts/content-security-policy.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import { buildContentSecurityPolicy } from "../src/lib/content-security-policy.mjs"; + +const proxySource = readFileSync(new URL("../src/proxy.ts", import.meta.url), "utf8"); +const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); +const jsonLdSource = readFileSync( + new URL("../src/components/json-ld.tsx", import.meta.url), + "utf8", +); +const vercelConfig = JSON.parse(readFileSync(new URL("../vercel.json", import.meta.url), "utf8")); + +function getDirective(policy, name) { + return policy + .split(";") + .map((directive) => directive.trim()) + .find((directive) => directive.startsWith(`${name} `)); +} + +test("production CSP permits only nonce-authorized inline scripts", () => { + const policy = buildContentSecurityPolicy("c2VjdXJlLW5vbmNl", false); + const scriptDirective = getDirective(policy, "script-src"); + + assert.match(scriptDirective, /'nonce-c2VjdXJlLW5vbmNl'/u); + assert.match(scriptDirective, /'strict-dynamic'/u); + assert.doesNotMatch(scriptDirective, /'unsafe-inline'/u); + assert.doesNotMatch(scriptDirective, /'unsafe-eval'/u); +}); + +test("development CSP permits React debugging without permitting arbitrary inline scripts", () => { + const policy = buildContentSecurityPolicy("c2VjdXJlLW5vbmNl", true); + const scriptDirective = getDirective(policy, "script-src"); + + assert.match(scriptDirective, /'unsafe-eval'/u); + assert.doesNotMatch(scriptDirective, /'unsafe-inline'/u); +}); + +test("CSP builder rejects values that could inject another directive", () => { + assert.throws(() => buildContentSecurityPolicy("nonce'; script-src *", false), /nonce/u); +}); + +test("proxy propagates a fresh nonce and CSP through request and response headers", () => { + assert.match(proxySource, /crypto\.randomUUID\(\)/u); + assert.match(proxySource, /requestHeaders\.set\("x-nonce", nonce\)/u); + assert.match( + proxySource, + /requestHeaders\.set\("Content-Security-Policy", contentSecurityPolicy\)/u, + ); + assert.match( + proxySource, + /response\.headers\.set\("Content-Security-Policy", contentSecurityPolicy\)/u, + ); + assert.match(proxySource, /_next\/static/u); + assert.match(proxySource, /next-router-prefetch/u); +}); + +test("custom inline scripts receive the request nonce", () => { + assert.match(layoutSource, /await headers\(\)/u); + assert.match(layoutSource, /