fix(m7): surface evidence whitelist in UI + filter the OS file picker
Reported by the user: the blue-side dropzone said "≤ 25 MB each" but nowhere did it list the accepted extensions, and the OS file picker showed "All files" — so an operator could spend the time picking a `.exe` only to get a 400 back. - New constants `EVIDENCE_ALLOWED_EXTENSIONS` + `EVIDENCE_MAX_BYTES` in `lib/missions.ts`. Manual mirror of the backend whitelist (commented cross-reference). One source of truth on the client. - Dropzone now prints `Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file` (testid `evidence-allowed-formats`). - File input gains `accept=".png,.jpg,..."` so the OS picker pre-filters to those extensions instead of "All files". - `handleFiles` rejects drag-and-drops of unsupported extensions on the client too (still re-checked server-side — defence in depth, not a security boundary). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -267,6 +267,27 @@ export const missionTestKeys = {
|
||||
detectionLevels: () => ['detection-levels'] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Evidence upload constraints. Mirror of the backend whitelist in
|
||||
* `app/services/evidence.py` — kept in sync by hand because exposing the
|
||||
* full whitelist via an endpoint would be a one-trip-too-many on every
|
||||
* page mount. If the backend list changes, update this array too.
|
||||
*/
|
||||
export const EVIDENCE_ALLOWED_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.log',
|
||||
'.json',
|
||||
'.csv',
|
||||
'.evtx',
|
||||
'.zip',
|
||||
] as const;
|
||||
|
||||
export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
||||
pending: 'Pending',
|
||||
executed: 'Executed',
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
EVIDENCE_ALLOWED_EXTENSIONS,
|
||||
EVIDENCE_MAX_BYTES,
|
||||
MISSION_TEST_STATE_ACCENT,
|
||||
MISSION_TEST_STATE_LABEL,
|
||||
VALID_TEST_TRANSITIONS,
|
||||
@@ -403,10 +405,19 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
|
||||
function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setDropError(null);
|
||||
// Per spec §M7, max 25 MB per file is server-enforced; this is just a
|
||||
// friendly UX guardrail so the user does not waste a roundtrip.
|
||||
// Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client
|
||||
// checks too so a drag-and-drop of a `.exe` doesn't waste a roundtrip
|
||||
// and the operator gets immediate feedback.
|
||||
for (const f of Array.from(files)) {
|
||||
if (f.size > 25 * 1024 * 1024) {
|
||||
const dotIdx = f.name.lastIndexOf('.');
|
||||
const ext = dotIdx >= 0 ? f.name.slice(dotIdx).toLowerCase() : '';
|
||||
if (!ext || !(EVIDENCE_ALLOWED_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
setDropError(
|
||||
`"${f.name}" has an unsupported extension. Accepted: ${EVIDENCE_ALLOWED_EXTENSIONS.join(', ')}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (f.size > EVIDENCE_MAX_BYTES) {
|
||||
setDropError(`"${f.name}" exceeds the 25 MB limit.`);
|
||||
continue;
|
||||
}
|
||||
@@ -494,13 +505,27 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
|
||||
>
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
{canWriteBlue
|
||||
? 'Drag files here or click to pick (≤ 25 MB each)'
|
||||
? 'Drag files here or click to pick'
|
||||
: 'Read-only — no upload permission'}
|
||||
</p>
|
||||
{canWriteBlue && (
|
||||
<p
|
||||
className="mt-1 font-mono text-3xs uppercase tracking-wider2 text-text-dim"
|
||||
data-testid="evidence-allowed-formats"
|
||||
>
|
||||
Accepted: {EVIDENCE_ALLOWED_EXTENSIONS.join(' · ')} ·{' '}
|
||||
max 25 MB / file
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
// `accept` is the native filter the OS file-picker honours. It
|
||||
// accepts a comma-separated list of extensions or MIME types.
|
||||
// Servers re-check both, so this is a UX nicety, not a security
|
||||
// boundary.
|
||||
accept={EVIDENCE_ALLOWED_EXTENSIONS.join(',')}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
data-testid="evidence-file-input"
|
||||
|
||||
Reference in New Issue
Block a user