Refuse the extension a whitespace-only name hid - #37
Merged
Conversation
jakejackson1
force-pushed
the
security/deny-list-bypass
branch
3 times, most recently
from
August 21, 2026 18:48
816e9f9 to
39d32ad
Compare
`Filename::extensionComponents()` normalized before it dropped the first
dot-separated field, and `normalizeComponents()` drops a field that is
nothing but spaces. `" .php"` therefore normalized to `['php']` and the
drop took that for the name, leaving `refuseBlockedExtensions()` nothing
to match. The file was written, and `mod_mime` splits it on the same dot
and runs it.
It splits with a bounded `explode()` now, so the first field is dropped
whatever it holds — the field `deviceComponent()` takes. Reachable only
from a `FileInfoInterface` of your own or a `resolveFilename()` override,
which is the case those refusals exist for; the shipped `FileInfo` never
produces an empty first field.
The deny-list gains nine entries with a documented execution path: `asis`
(mod_asis serves the file as a complete HTTP response), `erb`, `rhtml`,
`htr`, `idc`, `printer`, `cfm`, `cfml` and `cfc`.
`Filename::sanitizeForDisplay()` bounds its result at the new
`MAX_DISPLAY_LENGTH`, so a validation of your own cannot flood whatever
renders `getErrors()`. 2048 rather than `MAX_LENGTH`, because a message
naming a long allow-list is legitimately longer than a filename. The
bound is a parameter, so `FileList::describeKey()` names the 255 a field
name gets rather than cutting for itself.
`forceValidUtf8()` takes an incomplete trailing sequence off before it
converts. `symfony/polyfill-mbstring` converts through `iconv()`, which
answers `false` for a sequence the end of the string cuts short rather
than dropping it and keeping the rest, so a client name ending
mid-character came back as `unnamed-file` there — and since the polyfill
ships no `mb_strcut()`, a byte cut made one out of a name that arrived
whole. Only an incomplete tail matches, so a complete sequence at the end
survives; a lookbehind keeps the scan linear, since a run of lead bytes
otherwise retries at every offset.
The leading-dot refusal in `refuseUnsafeName()` was documented as covering
what the deny-list could not see. The deny-list sees `.htaccess` now, so
that rationale is restated as the one that still holds: it is a list of
extensions and does not cover `.env`. The CHANGELOG's account of what 3.x
did is scoped to the path it was ever true on: checked against 3.1.0, the
shipped `FileInfo` renamed `.htaccess` to `unnamed-file.htaccess` and
reduced `../escape.txt` to `escape.txt`, so only a `FileInfoInterface` of
your own reached the upload directory with either.
Two layers treat a space as significant and both are ASCII: `trim()` at
the ends of a name and of a deny-list component, and the
`rtrim($filename, " .")` that takes off what Windows resolves away.
Nothing pinned what the other space characters do, so the ASCII cases
that were covered would not have noticed a change to either. They are
left alone, at every position: a name keeps one leading, interior or
trailing; an extension carrying one is discarded whole; `\u{00A0}con` is
a file rather than the console; and a deny-list component keeps it, so
`php` beside one is not `php` and `evil.php\u{00A0}` is written under a
name nothing maps to the interpreter. Refusing that would mean inventing
a rule no file system applies, so the test asserts the name it is stored
under. `U+200B` is the exception and is covered as one, being a
zero-width mark in `Filename::BIDI_CONTROLS`; `U+2000`/`U+200A` bracket
the block that constant matches from `U+200B`, and `U+202F`/`U+205F` sit
just outside its other two byte ranges, so a change to any of the three
fails there. `tests/Upload/UnicodeSpaces.php` holds the set, required
from `tests/bootstrap.php` like the other fixtures nothing autoloads,
because the answer has to hold at both layers or they disagree about what
a filename is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jakejackson1
force-pushed
the
security/deny-list-bypass
branch
from
August 21, 2026 19:03
3fe1691 to
7b35973
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Three findings from a security audit of
src/.1. Deny-list bypass — a whitespace-only first component swallowed the extension
Filename::extensionComponents()normalized before it dropped the first dot-separated field:normalizeComponents()drops a field that is nothing but spaces, so" .php"normalized to['php']and thearray_slice()took that for the name.FileSystem::refuseBlockedExtensions()got an empty list and wrote the file:" .php"is a live PHP file — Apache'smod_mimesplits the name on dots and maps thephpfield to the handler, and nginx'slocation ~ \.php$matches it.None of the other refusals covered it:
refuseUnsafeName()checks for a leading dot, not a leading space, anddeviceComponent(" .php")trims to''.It splits with a bounded
explode()now, so the first field is dropped whatever it holds — the fielddeviceComponent()takes:Not reachable with the shipped classes.
FileInfo::setName()trims'.-_ 'and rewrites interior dots, so its output never has an empty first field —" .php"in becomesunnamed-file.phpout, which is refused. It is reachable from aFileInfoInterfaceof your own or aresolveFilename()override, which is the case those three refusals exist for and what the README promises they cover.FileSystemTest::testOverridingTheNamingSeamDoesNotDropTheNameRefusals()shows the empty-component drop was already known for a leading dot and compensated for inrefuseUnsafeName(). The compensation just didn't reach a leading space.2. Nine deny-list additions with a documented execution path
asis(Apachemod_asisserves the file as a complete HTTP response, so the uploader writes itsContent-TypeandLocation),erb,rhtml(Ruby templates),htr,idc,printer(legacy IIS ISAPI),cfm,cfml,cfc(ColdFusion).EXECUTABLE_EXTENSIONSgoes 59 → 68.3.
getErrors()entries were unbounded in lengthFileList::describeKey()cut a key atMAX_LENGTH;File::renderMessage()applied no cap, so a validation of your own with a runaway message floods whatever renders the list.Filename::sanitizeForDisplay()now bounds its result at a newMAX_DISPLAY_LENGTH(2048) — notMAX_LENGTH, because a message naming a long allow-list back to the developer is legitimately longer than a filename. The bound is a parameter, sodescribeKey()names the 255 a field name gets rather than cutting for itself.Documentation kept in step
Fixing (1) falsified a rationale carried in four places —
refuseUnsafeName()'s docblock, the test pinning it,CHANGELOG.mdandCLAUDE.mdall said the leading-dot refusal exists becauseextensionComponents()cannot see a dotfile's extension. It can now:.htaccesssplits to['htaccess']. The refusal still earns its place for the other half of the reason — a deny-list of extensions does not cover.env— and all four now say that.The README's
sanitizeForDisplay()row said "No length limit", and its deny-list prose carried a hand-counted "The first ten groups" that nothing verifies (the pinning test scrapes backticked words, not group labels). Both corrected.4.0.0 is unreleased, so the bypass was never in a tagged release and gets no
CHANGELOG.mdentry of its own; the executable-extension count and thegetErrors()sanitizing bullet were updated in place.Testing instructions
vendor/bin/phpunit --exclude-group mbstringalso passes, andgit archive HEAD | tar -t | sed 's:/.*::' | sort -uis unchanged.New regression coverage:
FilenameTest::provideNamesToSplit()— 9 cases on the splitter, including the whitespace-only nameFileSystemTest::providerNamesThatHideABlockedExtension()—' .php'through a fullupload()FilenameTest::provideTextToSanitize()— the display bound, and that it is not a filename budgetFilenameTest::testSanitizeTextRepairsASequenceTheBoundSplits()— the cut is a byte cut, so the UTF-8 repair runs after itFileTest::testARunawayValidationMessageIsBounded()— a validator throwing 100 KB yields a 2048-bytegetErrors()entryTo reproduce the original bypass, check out
mainand store aFileInfoInterfacewhosegetNameWithExtension()returns" .php".🤖 Generated with Claude Code