How to Check a File Extension
Check file extensions against an allowlist before reading bytes, so unsupported types are rejected early without buffering the whole upload into memory.
On this page
Introduction
The hardest part of reject unsupported types early is not writing the code — it is deciding what the code is allowed to reject. A extension allowlist setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in extension allowlist, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Choosing extension allowlist for reject unsupported types early is a trade, not a winner. You give up some flexibility to get a hard boundary, and the exchange pays off when the boundary catches a class of bugs that would otherwise surface at 3 a.m. Document the trade at the call site so the next reader does not undo the guard thinking it is a bug. The reader who does not know why the guard exists is the reader who removes it.
The sections below walk through the concrete decisions: where to put the extension allowlist check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Build the allowlist
Build the allowlist is the first decision because it determines what the rest of the extension allowlist pipeline can assume. A wrong choice here turns into reject unsupported types early under load, and the invariant you thought extension allowlist guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Build the allowlist is the one most teams skip: drive extension allowlist to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
const OK = new Set(['png','jpg','webp','pdf']);
function checkExt(name) {
const ext = name.toLowerCase().split('.').pop();
if (!OK.has(ext)) throw new Error('unsupported: ' + ext);
return ext;
}
The javascript above is the shape to ship for reject unsupported types early. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Check before reading bytes
The mechanism behind Check before reading bytes is straightforward in extension allowlist, but the consequences ripple. A wrong choice here turns into reject unsupported types early under load, and the invariant you thought extension allowlist guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Check before reading bytes is the one most teams skip: drive extension allowlist to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Handle case and double extensions
Handle case and double extensions is where most extension allowlist implementations leak performance. A wrong choice here turns into reject unsupported types early under load, and the invariant you thought extension allowlist guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Handle case and double extensions is the one most teams skip: drive extension allowlist to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('rejects double extension', () => {
expect(() => checkExt('evil.png.exe')).toThrow();
});
Pin the behavior with this test. A extension allowlist setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Log rejected extensions
The hard part of Log rejected extensions is not the happy path — it is what happens when extension allowlist fails halfway. A wrong choice here turns into reject unsupported types early under load, and the invariant you thought extension allowlist guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Log rejected extensions is the one most teams skip: drive extension allowlist to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Rotate the allowlist safely
Rotate the allowlist safely should be observable without a debugger. A wrong choice here turns into reject unsupported types early under load, and the invariant you thought extension allowlist guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Rotate the allowlist safely is the one most teams skip: drive extension allowlist to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test boundary extensions
Test boundary extensions is a trade, not a winner. A wrong choice here turns into reject unsupported types early under load, and the invariant you thought extension allowlist guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test boundary extensions is the one most teams skip: drive extension allowlist to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in reject unsupported types early all reduce to one mistake: treating extension allowlist as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the extension allowlist boundary so reject unsupported types early enters unvalidated.
- Assuming extension allowlist is fast without measuring the tail latency under load.
- Letting reject unsupported types early fail silently instead of failing loudly with a metric.
- Testing only the happy path so the extension allowlist failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: extension allowlist fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives extension allowlist to the failing condition and assert the system degrades gracefully. Then reject unsupported types early is a property you maintain, not a feature you hope for.
Sources: OWASP File Upload, MDN MIME
FAQ
- Is extension check enough?
- No. Combine with magic-byte sniffing for untrusted uploads.
- Handle files with no extension?
- Reject them. No extension means no declared type.
Report a problem with this articleOpens a pre-filled issue on GitHub.