diff --git a/src/core/api/dynamic-router.js b/src/core/api/dynamic-router.js
index eaef3c1..191bbf4 100644
--- a/src/core/api/dynamic-router.js
+++ b/src/core/api/dynamic-router.js
@@ -257,10 +257,16 @@ async function routeModuleRequest(request, path, method) {
const params = extractPathParams(route.path, pathString);
return await route.handler(request, params);
}
- } catch (authError) {
+ } catch (err) {
+ // Only known auth-error strings are safe to surface; all other
+ // exceptions are logged server-side and returned as a generic message.
+ const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
+ if (!SAFE_AUTH_MESSAGES.has(err.message)) {
+ console.error('[ZEN] Module route handler error:', err);
+ }
return {
success: false,
- error: authError.message
+ error: SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error'
};
}
}
diff --git a/src/core/api/handlers/users.js b/src/core/api/handlers/users.js
index 592a612..1b2eedf 100644
--- a/src/core/api/handlers/users.js
+++ b/src/core/api/handlers/users.js
@@ -8,7 +8,7 @@ import { cookies } from 'next/headers';
import { query, updateById } from '@zen/core/database';
import { getSessionCookieName } from '../../../shared/lib/appConfig.js';
import { updateUser } from '../../../features/auth/lib/auth.js';
-import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
+import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
// Get cookie name from environment or use default
const COOKIE_NAME = getSessionCookieName();
@@ -425,11 +425,17 @@ export async function handleUploadProfilePicture(request) {
// Generate storage path
const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename);
- // Derive the authoritative content-type from the server-side whitelist —
- // never trust the client-supplied file.type, which is fully attacker-controlled.
- const contentType = ALLOWED_IMAGE_MIME_TYPES.has(file.type)
- ? file.type
- : 'application/octet-stream';
+ // Derive the authoritative content-type from the *validated file extension*,
+ // never from file.type which is fully attacker-controlled. The extension
+ // has already been verified against the allowedTypes whitelist above, so
+ // mapping it deterministically here eliminates any client influence over
+ // the MIME type stored in R2 and subsequently served to other users.
+ const EXTENSION_TO_MIME = {
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
+ '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
+ };
+ const ext = getFileExtension(file.name).toLowerCase();
+ const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
// Upload to storage
const uploadResult = await uploadImage({
diff --git a/src/core/api/nx-route.js b/src/core/api/nx-route.js
index 276c1b1..4c39f21 100644
--- a/src/core/api/nx-route.js
+++ b/src/core/api/nx-route.js
@@ -19,18 +19,33 @@ export async function GET(request, { params }) {
// Check if this is a file response (from storage endpoint)
if (response.success && response.file) {
+ const contentType = response.file.contentType || 'application/octet-stream';
const headers = {
- 'Content-Type': response.file.contentType || 'application/octet-stream',
+ 'Content-Type': contentType,
'Content-Length': response.file.contentLength?.toString() || '',
'Cache-Control': 'private, max-age=3600',
'Last-Modified': response.file.lastModified?.toUTCString() || new Date().toUTCString(),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
};
- if (response.file.filename) {
+
+ // Always emit an explicit Content-Disposition header — omitting it leaves
+ // rendering decisions to browser heuristics, which varies by content-type
+ // and browser version. Image MIME types are served inline (required for
+ //
tags); every other type forces a download to prevent in-browser
+ // rendering of potentially dangerous content.
+ const INLINE_MIME_TYPES = new Set([
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
+ ]);
+ if (INLINE_MIME_TYPES.has(contentType)) {
+ headers['Content-Disposition'] = 'inline';
+ } else if (response.file.filename) {
const encoded = encodeURIComponent(response.file.filename);
headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
+ } else {
+ headers['Content-Disposition'] = 'attachment';
}
+
return new NextResponse(response.file.body, { status: 200, headers });
}
diff --git a/src/core/api/router.js b/src/core/api/router.js
index 0509895..1b89e73 100644
--- a/src/core/api/router.js
+++ b/src/core/api/router.js
@@ -279,10 +279,18 @@ async function routeModuleRequest(request, path, method) {
const params = extractPathParams(route.path, pathString);
return await route.handler(request, params);
}
- } catch (authError) {
+ } catch (err) {
+ // Only the two known auth-error strings are safe to surface verbatim.
+ // Any other exception (database errors, upstream API errors, etc.) must
+ // never reach the client raw — they can contain credentials, table names,
+ // or internal hostnames. Log the full error server-side only.
+ const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
+ if (!SAFE_AUTH_MESSAGES.has(err.message)) {
+ console.error('[ZEN] Module route handler error:', err);
+ }
return {
success: false,
- error: authError.message
+ error: SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error'
};
}
}
diff --git a/src/core/storage/utils.js b/src/core/storage/utils.js
index 2221bc0..ec3196e 100644
--- a/src/core/storage/utils.js
+++ b/src/core/storage/utils.js
@@ -243,13 +243,56 @@ export const FILE_SIZE_LIMITS = {
LARGE_FILE: 1024 * 1024 * 1024, // 1 GB
};
+/**
+ * Known magic-byte sequences for each permitted image extension.
+ * Keyed by lower-case extension; value is the expected byte sequence at offset 0.
+ * WEBP requires a special compound check (RIFF....WEBP).
+ */
+const MAGIC_BYTES = {
+ '.jpg': [0xFF, 0xD8, 0xFF],
+ '.jpeg': [0xFF, 0xD8, 0xFF],
+ '.png': [0x89, 0x50, 0x4E, 0x47],
+ '.gif': [0x47, 0x49, 0x46, 0x38], // GIF87a or GIF89a
+};
+
+/**
+ * Confirm that the file buffer starts with the magic bytes expected for the
+ * file's declared extension. This is a *positive* assertion — the file must
+ * prove it is the type it claims to be, not merely fail to match a denylist.
+ *
+ * A polyglot that prefixes a real image header before malicious payload still
+ * satisfies this check, so this function must always be used together with
+ * inspectBufferForDangerousContent which scans a larger region for script tags.
+ *
+ * @param {Buffer} buffer
+ * @param {string} filename
+ * @returns {boolean} true when the magic bytes match the declared extension
+ */
+export function validateMagicBytes(buffer, filename) {
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return false;
+ const ext = getFileExtension(filename).toLowerCase();
+
+ // WebP: 'RIFF' at bytes 0-3, 'WEBP' at bytes 8-11
+ if (ext === '.webp') {
+ return buffer.slice(0, 4).toString('ascii') === 'RIFF'
+ && buffer.slice(8, 12).toString('ascii') === 'WEBP';
+ }
+
+ const expected = MAGIC_BYTES[ext];
+ if (!expected) return false;
+ return expected.every((byte, i) => buffer[i] === byte);
+}
+
/**
* Inspect the first bytes of a buffer for known-dangerous content signatures
* that could indicate an HTML, SVG, or XML file disguised with an image extension.
* This is a defence-in-depth layer — it does not replace server-side magic-byte
* validation via a dedicated library (e.g. 'sharp' or 'file-type').
*
- * @param {Buffer} buffer - First bytes of the uploaded file (minimum 16 bytes)
+ * The scan window is extended to 512 bytes to make it harder to hide a script
+ * tag after a short but valid-looking binary header.
+ *
+ * @param {Buffer} buffer - File buffer (ideally the full file, at least 512 bytes)
* @returns {{ safe: boolean, reason: string|null }}
*/
export function inspectBufferForDangerousContent(buffer) {
@@ -257,13 +300,15 @@ export function inspectBufferForDangerousContent(buffer) {
return { safe: false, reason: 'Buffer too short to inspect' };
}
- // Convert the first 64 bytes to a lower-case ASCII string for pattern matching.
- const head = buffer.slice(0, Math.min(buffer.length, 64)).toString('latin1').toLowerCase();
+ // Scan the first 512 bytes (up from 64) to reduce polyglot attack surface.
+ const head = buffer.slice(0, Math.min(buffer.length, 512)).toString('latin1').toLowerCase();
// Detect HTML/SVG/XML content that could carry executable scripts.
const dangerousPatterns = [
/^<(!doctype|html|svg|xml|script)/,
/^[\s\ufeff]*<(!doctype|html|svg|xml|script)/,
+ /