fix(audit-wave-11): asset hygiene + datetime correctness

**asset-auditor C1+C2+H1+H3 — image normalization**

Add `src/lib/services/image-normalize.ts` and wire it into
`uploadFile()` so every accepted image is re-encoded via sharp before
hitting storage:

- Strips EXIF (GPS coords, device serial, photographer) so uploaded
  photos don't leak per-pixel PII to anyone with a download URL (C1).
- Caps dimensions at 4096px via `resize({fit:'inside',withoutEnlargement:true})`
  so a 30000×30000 palette PNG can't decompression-bomb a downstream
  sharp decode (C2).
- Re-encode drops polyglot trailers (PDF+JPEG sandwiches that beat
  the prefix-only magic-byte check) (H1).
- Freezes animated GIFs to first frame (H3).

Avatar route already funnels through uploadFile so it's covered by
the single change.

**asset-auditor M2 — sanitizeFilename strips RTL/zero-width**

Add Unicode NFC + a strip of bidi-control (U+202A-U+202E, U+2066-U+2069)
+ zero-width chars (U+200B-U+200F, U+FEFF) to `sanitizeFilename`.
Closes the classic Windows-icon-spoof vector
(`invoice_‮fdp.exe` displaying as `invoice_exe.pdf`) plus folder-listing
collision spoofs.

**datetime-auditor C1 — reminder dueAt drift on every save**

The `<input type="datetime-local">` round-trip in reminder-form.tsx
used `iso.slice(0,16)` (load) and `new Date(value).toISOString()`
(submit). The slice drops the `Z` so a UTC instant is mis-interpreted
as local on load, then converted back to UTC on save — every save
of an existing Warsaw reminder drifted backwards by 2h (CEST). After
two saves the reminder appears at 06:00 instead of 10:00.

Add `toLocalDatetimeLocal(d: Date)` helper that builds the local
YYYY-MM-DDTHH:MM string from getter methods so the round-trip is
TZ-safe. snooze-dialog already did this correctly; the contact-log
dialog also uses the correct localIsoString pattern.

**datetime-auditor C2 — BullMQ cron in UTC, not port-local**

`upsertJobScheduler` defaulted `tz` to UTC. Patterns like
`0 8 * * *` were intended as "8 AM Warsaw" but fired at 09:00 winter
/ 10:00 summer. Pass `tz: process.env.SCHEDULER_TZ ?? 'Europe/Warsaw'`.
Sub-hourly / hourly patterns are TZ-invariant and stay UTC.

**datetime-auditor C3 — report-scheduler never advanced next_run_at**

The minutely scheduler selected `nextRunAt <= now()` and enqueued
generate-report — but never bumped nextRunAt. For weekly/monthly
reports this meant the job re-fired every single minute until a
human zeroed the row out, flooding recipients with dupes.

Now uses `cron-parser` (added as a dep) to compute the next fire
from `report.schedule` and UPDATEs the row BEFORE the enqueue.
Malformed cron expressions disable the row instead of re-attempting
every minute.

Tests 1315/1315. Migration 0058 applied via psql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 12:58:58 +02:00
parent 72237a0191
commit 2496911dc4
8 changed files with 202 additions and 18 deletions

View File

@@ -59,6 +59,29 @@ export async function uploadFile(
throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`);
}
// Image normalisation (asset-auditor C1+C2+H1+H3): re-encode via
// sharp to strip EXIF (incl. GPS), drop polyglot trailing bytes, cap
// dimensions against decompression-bomb PNGs, and freeze animated
// GIFs to a single frame. Skips when sharp isn't available or the
// declared MIME isn't an image; failures fall back to the original
// buffer with a warning.
let normalizedBuffer = file.buffer;
let normalizedSize = file.size;
if (file.mimeType.startsWith('image/')) {
try {
const { normalizeImage } = await import('@/lib/services/image-normalize');
const normalized = await normalizeImage(file.buffer, file.mimeType);
normalizedBuffer = normalized.buffer;
normalizedSize = normalized.buffer.byteLength;
} catch (err) {
const { logger } = await import('@/lib/logger');
logger.warn(
{ err, mimeType: file.mimeType },
'image normalization failed; storing original (EXIF retained)',
);
}
}
const entity = data.entityType ?? 'general';
const entityId = data.entityId ?? portId;
const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
@@ -66,9 +89,9 @@ export async function uploadFile(
const sanitizedFilename = sanitizeFilename(data.filename);
const backend = await getStorageBackend();
await backend.put(storagePath, file.buffer, {
await backend.put(storagePath, normalizedBuffer, {
contentType: file.mimeType,
sizeBytes: file.size,
sizeBytes: normalizedSize,
});
// E8: auto-set entity FK from system-managed folder when the rep uploads
@@ -82,7 +105,7 @@ export async function uploadFile(
filename: sanitizedFilename,
originalName: sanitizedOriginal,
mimeType: file.mimeType,
sizeBytes: String(file.size),
sizeBytes: String(normalizedSize),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: data.category ?? null,
@@ -288,10 +311,9 @@ export async function getFileById(id: string, portId: string) {
* download endpoint, which presigns from the bucket using the id, not the
* raw path.
*/
export type AggregatedFileRow = Omit<
typeof files.$inferSelect,
'storagePath' | 'storageBucket'
> & { signedFromDocumentId: string | null };
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
signedFromDocumentId: string | null;
};
export interface AggregatedFileGroup {
label: string;

View File

@@ -0,0 +1,60 @@
/**
* Server-side image normalisation. Single funnel for every image
* upload (avatar, generic file, scan, attachment) to:
*
* - **Strip EXIF** (incl. GPS coords, device serial, photographer name)
* so uploaded photos don't leak per-pixel PII to anyone with a
* download URL. asset-auditor C1.
* - **Cap dimensions** at MAX_DIMENSION so a 30000×30000 palette PNG
* can't decompression-bomb a sharp decode further downstream. C2.
* - **Re-encode via sharp** so polyglot trailing bytes (PDF+JPEG
* sandwiches, HTML+PNG) are dropped — the output buffer is a clean
* single-format file regardless of input trickery. H1.
* - **Freeze animated GIFs** to first frame so a 5000-frame phishing
* GIF can't pin a worker on every list-view render. H3.
*
* Falls through to the original buffer (with a warning at the call
* site) when sharp isn't available or the input isn't a recognised
* image. The MIME type stays the same as declared — magic-byte
* verification has already run upstream.
*/
const MAX_DIMENSION = 4096;
export async function normalizeImage(
input: Buffer,
mimeType: string,
): Promise<{ buffer: Buffer; format: string }> {
const { default: sharp } = await import('sharp');
// Map MIME → sharp output format. Stay in-format so the stored
// contentType keeps matching the bytes.
const format = mimeFormat(mimeType);
let pipeline = sharp(input, { animated: false, pages: 1 })
.rotate() // honour EXIF orientation BEFORE stripping it
.resize({
width: MAX_DIMENSION,
height: MAX_DIMENSION,
fit: 'inside',
withoutEnlargement: true,
})
.withMetadata({ orientation: undefined });
// toFormat() drops anything non-format-shaped after re-encode.
if (format === 'jpeg') pipeline = pipeline.jpeg({ quality: 88, mozjpeg: true });
else if (format === 'png') pipeline = pipeline.png({ compressionLevel: 9 });
else if (format === 'webp') pipeline = pipeline.webp({ quality: 88 });
else if (format === 'gif') pipeline = pipeline.gif();
const buffer = await pipeline.toBuffer();
return { buffer, format };
}
function mimeFormat(mimeType: string): 'jpeg' | 'png' | 'webp' | 'gif' | 'unknown' {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return 'jpeg';
if (mimeType === 'image/png') return 'png';
if (mimeType === 'image/webp') return 'webp';
if (mimeType === 'image/gif') return 'gif';
return 'unknown';
}

View File

@@ -13,10 +13,19 @@ export function generateStorageKey(
}
export function sanitizeFilename(name: string): string {
return name
.replace(/[/\\:]/g, '') // strip path chars
.replace(/\x00/g, '') // strip null bytes
.replace(/[\x01-\x1f\x7f]/g, '') // strip control chars
.trim()
.slice(0, 255);
return (
name
.normalize('NFC')
.replace(/[/\\:]/g, '') // strip path chars
.replace(/\x00/g, '') // strip null bytes
.replace(/[\x01-\x1f\x7f]/g, '') // strip control chars
// asset-auditor M2: strip Unicode bidi-control + zero-width
// characters that enable the classic Windows-icon spoof
// (`invoice_fdp.exe` renders as `invoice_exe.pdf`) and folder-
// listing collision spoofs (U+200B/U+FEFF).
.replace(/[---]/g, '')
.trim()
.slice(0, 255)
);
}