From 5723da21aae8b69642a39e4b5a05ad84fa8dd8ef Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Thu, 12 Mar 2026 20:03:08 +0100 Subject: [PATCH] fix(security): harden hashing in image-proxy (#5188) --- packages/image-proxy/src/index.ts | 44 +++++++++++++------------------ 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/image-proxy/src/index.ts b/packages/image-proxy/src/index.ts index 2cef8b8ea..3660064c3 100644 --- a/packages/image-proxy/src/index.ts +++ b/packages/image-proxy/src/index.ts @@ -1,6 +1,7 @@ -import { createHash, randomBytes } from "node:crypto"; +import { createHmac, hkdfSync } from "node:crypto"; import { createId } from "@homarr/common"; +import { env } from "@homarr/common/env"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -9,29 +10,22 @@ import { createGetSetChannel } from "@homarr/redis"; const logger = createLogger({ module: "imageProxy" }); -const createHashChannel = (hash: `${string}.${string}`) => createGetSetChannel(`image-proxy:hash:${hash}`); +const createHmacChannel = (hmac: `${string}.${string}`) => createGetSetChannel(`image-proxy:hmac:${hmac}`); const createUrlByIdChannel = (id: string) => createGetSetChannel<{ url: `${string}.${string}`; headers: `${string}.${string}`; }>(`image-proxy:url:${id}`); -const saltChannel = createGetSetChannel("image-proxy:salt"); export class ImageProxy { - private static salt: string | null = null; - private async getOrCreateSaltAsync(): Promise { - if (ImageProxy.salt) return ImageProxy.salt; - const existingSalt = await saltChannel.getAsync(); - if (existingSalt) { - ImageProxy.salt = existingSalt; - return existingSalt; - } + private static hmacKey: Buffer | null = null; - const salt = randomBytes(32).toString("hex"); - logger.debug("Generated new salt for image proxy", { salt }); - ImageProxy.salt = salt; - await saltChannel.setAsync(salt); - return salt; + /** + * Generate secure key for image-proxy from encryption key + */ + private static getHmacKey(): Buffer { + this.hmacKey ??= Buffer.from(hkdfSync("sha256", env.SECRET_ENCRYPTION_KEY, "", "image-proxy", 32)); + return this.hmacKey; } public async createImageAsync(url: string, headers?: Record): Promise { @@ -106,20 +100,18 @@ export class ImageProxy { } private async getExistingIdAsync(url: string, headers: Record | undefined): Promise { - const salt = await this.getOrCreateSaltAsync(); - const urlHash = this.createDeterministicHash(url, salt); - const headerHash = this.createDeterministicHash(JSON.stringify(headers ?? null), salt); + const urlHash = this.hashSecret(url); + const headerHash = this.hashSecret(JSON.stringify(headers ?? null)); - const channel = createHashChannel(`${urlHash}.${headerHash}`); + const channel = createHmacChannel(`${urlHash}.${headerHash}`); return await channel.getAsync(); } private async storeImageAsync(id: string, url: string, headers: Record | undefined): Promise { - const salt = await this.getOrCreateSaltAsync(); - const urlHash = this.createDeterministicHash(url, salt); - const headerHash = this.createDeterministicHash(JSON.stringify(headers ?? null), salt); + const urlHash = this.hashSecret(url); + const headerHash = this.hashSecret(JSON.stringify(headers ?? null)); - const hashChannel = createHashChannel(`${urlHash}.${headerHash}`); + const hashChannel = createHmacChannel(`${urlHash}.${headerHash}`); const urlHeaderChannel = createUrlByIdChannel(id); await urlHeaderChannel.setAsync({ url: encryptSecret(url), @@ -134,8 +126,8 @@ export class ImageProxy { }); } - private createDeterministicHash(value: string, salt: string): string { - return createHash("sha256").update(`${salt}:${value}`).digest("hex"); + private hashSecret(value: string): string { + return createHmac("sha256", ImageProxy.getHmacKey()).update(value).digest("hex"); } private redactUrl(url: string): string {