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,
|
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> = {
|
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
executed: 'Executed',
|
executed: 'Executed',
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import {
|
import {
|
||||||
|
EVIDENCE_ALLOWED_EXTENSIONS,
|
||||||
|
EVIDENCE_MAX_BYTES,
|
||||||
MISSION_TEST_STATE_ACCENT,
|
MISSION_TEST_STATE_ACCENT,
|
||||||
MISSION_TEST_STATE_LABEL,
|
MISSION_TEST_STATE_LABEL,
|
||||||
VALID_TEST_TRANSITIONS,
|
VALID_TEST_TRANSITIONS,
|
||||||
@@ -403,10 +405,19 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
|
|||||||
function handleFiles(files: FileList | null) {
|
function handleFiles(files: FileList | null) {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
setDropError(null);
|
setDropError(null);
|
||||||
// Per spec §M7, max 25 MB per file is server-enforced; this is just a
|
// Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client
|
||||||
// friendly UX guardrail so the user does not waste a roundtrip.
|
// 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)) {
|
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.`);
|
setDropError(`"${f.name}" exceeds the 25 MB limit.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -494,13 +505,27 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
|
|||||||
>
|
>
|
||||||
<p className="font-mono text-2xs text-text-dim">
|
<p className="font-mono text-2xs text-text-dim">
|
||||||
{canWriteBlue
|
{canWriteBlue
|
||||||
? 'Drag files here or click to pick (≤ 25 MB each)'
|
? 'Drag files here or click to pick'
|
||||||
: 'Read-only — no upload permission'}
|
: 'Read-only — no upload permission'}
|
||||||
</p>
|
</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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
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"
|
className="hidden"
|
||||||
onChange={(e) => handleFiles(e.target.files)}
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
data-testid="evidence-file-input"
|
data-testid="evidence-file-input"
|
||||||
|
|||||||
Reference in New Issue
Block a user