web-files-file-upload-patterns-218b34 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-files-file-upload-patterns-218b34 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Quick Guide: A dropzone is a keyboard-operable button wrapping a hidden file input, with drag as an enhancement. Validate for the user's benefit on the client — extension, MIME type, then the file's own magic bytes — and again on the server, because none of the client checks are security. Progress needsXMLHttpRequest;fetchhas no upload progress event. Past roughly 100MB, chunk the file so a failure costs one chunk. Large files go straight to storage on a presigned URL the server issues, so no request body is ever proxied.
Detailed Resources:
The destination decides almost everything else.
POST with FormData, progress from XHR, and a sizecap the server can enforce. examples/core.md and examples/progress.md are the whole of it.
it directly, so no bytes pass through your application. examples/presigned-upload.md.
limit, and record which parts landed so a retry resumes. examples/resumable.md.
<critical_requirements>
Validate on the server as well as in the browser. Client validation exists to tell the user quickly what will be rejected; anyone can skip it entirely, so it settles nothing about safety.
Read the file's first bytes when the type matters. Extensions and MIME types are both supplied by whoever made the file, and a renamed executable passes every check that trusts them.
Revoke every object URL you create. A preview holds the whole file in memory until URL.revokeObjectURL() runs, so a user who changes their mind three times leaks three files.
Make the dropzone reachable from the keyboard. role="button", tabIndex={0} and an Enter/Space handler that opens the file dialog, with drag layered on top — mobile has no drag at all, so the click path is the real one.
Have the server issue a short-lived presigned URL rather than proxying the body. The upload then costs your application nothing, and no storage credential is ever in reach of the browser.
</critical_requirements>
Auto-detection: dropzone, dataTransfer.files, dragenter, dragleave, dragover, input type="file", event.target.files, accept attribute, xhr.upload.addEventListener, lengthComputable, presigned URL, uploadUrl, multipart upload, UploadPart, ETag, chunked upload, file.slice, Content-Range, resumable upload, tus, Tus-Resumable, Upload-Offset, magic bytes, file signature, FormData append file
Applies to:
Handled elsewhere:
File it isgiven
<philosophy>
An upload is three independent problems that get conflated: choosing a file, checking it, and moving its bytes. Keeping them separate is what makes any of them replaceable.
The checking half has a rule that never bends. Client validation is a user-experience feature, and the server's is the only one that is a control. Everything the browser knows about a file — its name, its extension, its type — came from the file itself. Reading magic bytes raises the bar but does not change the category: it is still a check the client can be made to skip.
The moving half scales by a different axis: not how many files, but how long a single request is open. A short request can fail and be retried whole. A long one accumulates the probability of a dropped connection until retrying whole is unacceptable, and that is the point at which chunking starts paying for its complexity — not at a particular byte count.
</philosophy>
<patterns>
Count drag events rather than tracking a boolean. dragenter and dragleave fire for every nested element, so a boolean flickers off the moment the pointer crosses a child.
const dragCounterRef = useRef(0);
<div
onDragEnter={() => { dragCounterRef.current++; setState("drag-over"); }}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setState("idle");
}}
onDragOver={(e) => e.preventDefault()} // without this, drop never fires
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label="File upload area. Click or drag files to upload."
>
<input ref={inputRef} type="file" hidden aria-hidden="true" tabIndex={-1} />
</div>Full code: examples/core.md
One entry per file with its own status, so a failure is per-file rather than per-batch. Rejections come back with reasons the UI can show.
interface FileWithId {
id: string;
file: File;
preview?: string;
status: "pending" | "uploading" | "success" | "error";
progress: number;
error?: string;
}
// addFiles returns { added, rejected }, each rejection carrying its reason
// removeFile and clearFiles revoke any preview URL before dropping the entryFull code: examples/core.md
fetch reports download progress and not upload progress, so upload progress means XMLHttpRequest. Average the last few samples or the speed reading jitters unusably.
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return; // no total: show a spinner, not a bar
const speed = rollingAverageSpeed(event.loaded, performance.now());
setProgress({
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
speed,
remainingTime: (event.total - event.loaded) / speed,
});
});xhr.abort() is the cancel. Streaming a fetch body measures bytes you handed the stream rather than bytes on the wire, which is why it is not a substitute.
Full code: examples/progress.md
Read the first twelve bytes and compare against known signatures. Never read the whole file — a large one freezes the tab.
const FILE_SIGNATURES = [
{ mime: "image/jpeg", extension: "jpg", signature: [0xff, 0xd8, 0xff] },
{ mime: "image/png", extension: "png", signature: [0x89, 0x50, 0x4e, 0x47] },
{
mime: "application/pdf",
extension: "pdf",
signature: [0x25, 0x50, 0x44, 0x46],
},
{
mime: "application/zip",
extension: "zip",
signature: [0x50, 0x4b, 0x03, 0x04],
},
];
const buffer = await file.slice(0, 12).arrayBuffer();
const bytes = new Uint8Array(buffer);Office documents are ZIP archives, so a ZIP match needs a second look: word/, xl/ or ppt/ in the first kilobyte identifies which.
Full code: examples/validation.md
Four steps, and your application never holds the bytes:
PUTs the file to that URL.const { uploadUrl, key } = await requestPresignedUrl(file);
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.addEventListener("progress", reportProgress);
xhr.send(file);A POST-policy URL instead of a PUT lets the storage service enforce size and content-type itself — at the cost of FormData field order mattering, with the file appended last.
Full code: examples/presigned-upload.md
Slice the file, upload the slices with a concurrency limit, and retry a failed slice with exponential backoff rather than restarting.
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const start = chunkIndex * chunkSize;
const chunk = file.slice(start, Math.min(start + chunkSize, file.size));Persist the completed chunk indexes keyed on name-size-lastModified, so a reload resumes rather than restarts, and expire that record after a day. For an interoperable protocol rather than your own, tus is POST to create, HEAD to learn the offset, PATCH to append.
Full code: examples/resumable.md
</patterns>
<red_flags>
Breaks at runtime:
event.preventDefault() on dragover — drop never fires and the browser navigates to thefile instead — prevent the default on both dragover and drop
dragenterand dragleave in a ref
change event — assign event.target.value = "" after reading files
fetch used where progress is required — there is no upload progress event and stream progressmeasures the wrong thing — use XMLHttpRequest
file.text() or readAsDataURL() to inspect a type — the whole file is read into memory — slicethe first 12 bytes
effect and revoke in the cleanup
and the methods, and expose ETag for multipart
Surprising behaviour:
File.type all come from the client and are all forgeablelengthComputable is false for a request with no known length, and the percentage is meaninglessuntil it is true
lifecycle rule to expire them
not
</red_flags>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.