Skip to content

Repository files navigation

file-input-hardening

OpenSSF Best Practices

Five ways a web file upload silently fails — and one small, zero-dependency module that fixes all of them.

import {
  openPickerHardened,
  preventGlobalFileDrop,
  wirePasteUpload,
  drainEarlyPick,
  saveBlobHardened,
} from "file-input-hardening";

function init(input: HTMLInputElement, { signal }: { signal: AbortSignal }) {
  preventGlobalFileDrop(signal);
  wirePasteUpload((file) => handleFile(file), signal);

  input.addEventListener("change", () => {
    const file = input.files?.[0];
    if (file) handleFile(file);
    input.value = ""; // same-file re-pick must fire change again
  }, { signal });

  const early = drainEarlyPick(input);
  if (early) handleFile(early);

  uploadButton.addEventListener("click", () => openPickerHardened(input), { signal });
}

The five silent failure modes

These are the "user clicked and nothing happened" bugs — impossible to debug from logs because the events simply never fire. All verified in production and against browser bug trackers.

# Failure Fix
1 Picking the same file twice doesn't fire change (Chrome bug 27849 — value didn't change) openPickerHardened() clears value before .click()
2 Dropping a file outside the dropzone navigates the browser to the file — the page blanks, looks like a crash preventGlobalFileDrop() — global preventDefault, file drags only
3 Ctrl/Cmd+V of a screenshot does nothing wirePasteUpload() — document-level paste, skips text fields
4 File picked before your deferred JS loaded is silently dropped; page looks dead until refresh drainEarlyPick() recovers it from element state
5 <a download> silently no-ops on iOS Safari; Chrome's short revoke window cancels slow downloads (bug 1126316) saveBlobHardened() — iOS share sheet, window.open fallback, 30 s revoke

Install

npm install file-input-hardening

Zero dependencies. Browser-only (attaches DOM listeners); importing in SSR code is safe — browser APIs are only touched when the functions run.

API

openPickerHardened(input): void

Call synchronously inside the click gesture — a setTimeout/await before it drops the transient user activation and iOS Safari silently refuses to open the picker (WebKit bug 225559).

preventGlobalFileDrop(signal): void

Once per page/component init.

wirePasteUpload(onImage, signal): void

Calls onImage(file) with the first image on the clipboard. Attached to document — paste is a global gesture; users don't focus dropzones first.

drainEarlyPick(input): File | null

Call once after wiring your real change listener. Works with no setup (reads the selection back from input.files — the event notification was lost, the file wasn't). For the strongest guarantee, also inline this tiny guard in your HTML <head> so a pre-module change parks the File:

<script>
  addEventListener("change", (e) => {
    if (window.__fileInputMounted) return;
    const t = e.target;
    if (t instanceof HTMLInputElement && t.type === "file" && t.files?.[0]) {
      t.__earlyPick = t.files[0];
      t.setAttribute("data-early-pick", "");
    }
  }, true);
</script>

saveBlobHardened(blob, filename): boolean

Returns false only when every save path failed — surface an error to the user in that case. Never throws.

triggerUrlDownload(url, filename?): void

Download a remote URL (CDN file) without buffering it through fetchblob first — the browser streams it and the download starts instantly on the gesture.

Caveats

  • The AbortSignal is your cleanup: pass the same signal you use for the rest of the component and abort it on unmount. Nothing else to tear down.
  • saveBlobHardened intentionally does NOT use showSaveFilePicker: its transient user activation is consumed by any await before the call (Chromium issue 1193489), which makes real tool flows (async encode → save) throw an AbortError indistinguishable from a user cancel. Squoosh and TinyPNG use plain <a download> for the same reason.
  • Filename renaming via download only applies same-origin; cross-origin names come from Content-Disposition.

License

MIT.


Extracted from the production code of RoundCut — free, in-browser image tools — where these helpers run on every upload surface across 29 languages.

About

Five ways a web file upload silently fails — same-file re-pick, drop-outside-dropzone, paste, the early-pick race, iOS downloads — fixed in one zero-dependency module

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages