Skip to content

Refuse the extension a whitespace-only name hid - #37

Merged
jakejackson1 merged 1 commit into
mainfrom
security/deny-list-bypass
Aug 24, 2026
Merged

Refuse the extension a whitespace-only name hid#37
jakejackson1 merged 1 commit into
mainfrom
security/deny-list-bypass

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

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:

return array_slice(self::normalizeComponents($filename), 1);

normalizeComponents() drops a field that is nothing but spaces, so " .php" normalized to ['php'] and the array_slice() took that for the name. FileSystem::refuseBlockedExtensions() got an empty list and wrote the file:

STORED   " .php"       -> …/uploads/ .php
STORED   " .htaccess"  -> …/uploads/ .htaccess
STORED   " .svg"       -> …/uploads/ .svg

" .php" is a live PHP file — Apache's mod_mime splits the name on dots and maps the php field to the handler, and nginx's location ~ \.php$ matches it.

None of the other refusals covered it: refuseUnsafeName() checks for a leading dot, not a leading space, and deviceComponent(" .php") trims to ''.

It splits with a bounded explode() now, so the first field is dropped whatever it holds — the field deviceComponent() takes:

$parts = explode('.', $filename, 2);

return isset($parts[1]) ? self::normalizeComponents($parts[1]) : [];

Not reachable with the shipped classes. FileInfo::setName() trims '.-_ ' and rewrites interior dots, so its output never has an empty first field — " .php" in becomes unnamed-file.php out, which is refused. It is reachable from a FileInfoInterface of your own or a resolveFilename() 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 in refuseUnsafeName(). The compensation just didn't reach a leading space.

2. Nine deny-list additions with a documented execution path

asis (Apache mod_asis serves the file as a complete HTTP response, so the uploader writes its Content-Type and Location), erb, rhtml (Ruby templates), htr, idc, printer (legacy IIS ISAPI), cfm, cfml, cfc (ColdFusion). EXECUTABLE_EXTENSIONS goes 59 → 68.

3. getErrors() entries were unbounded in length

FileList::describeKey() cut a key at MAX_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 new MAX_DISPLAY_LENGTH (2048) — not MAX_LENGTH, because a message naming a long allow-list back to the developer is legitimately longer than a filename. The bound is a parameter, so describeKey() 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.md and CLAUDE.md all said the leading-dot refusal exists because extensionComponents() cannot see a dotfile's extension. It can now: .htaccess splits 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.md entry of its own; the executable-extension count and the getErrors() sanitizing bullet were updated in place.

Testing instructions

composer phpunit        # 565 tests, 1 skip (the cross-file-system one)
composer phpstan        # level 9, src + tests
composer lint
composer check-syntax
composer i18n:pot       # regenerates with no diff
composer psr7-readme
composer translator-readme

vendor/bin/phpunit --exclude-group mbstring also passes, and git archive HEAD | tar -t | sed 's:/.*::' | sort -u is unchanged.

New regression coverage:

  • FilenameTest::provideNamesToSplit() — 9 cases on the splitter, including the whitespace-only name
  • FileSystemTest::providerNamesThatHideABlockedExtension()' .php' through a full upload()
  • FilenameTest::provideTextToSanitize() — the display bound, and that it is not a filename budget
  • FilenameTest::testSanitizeTextRepairsASequenceTheBoundSplits() — the cut is a byte cut, so the UTF-8 repair runs after it
  • FileTest::testARunawayValidationMessageIsBounded() — a validator throwing 100 KB yields a 2048-byte getErrors() entry

To reproduce the original bypass, check out main and store a FileInfoInterface whose getNameWithExtension() returns " .php".

🤖 Generated with Claude Code

@jakejackson1
jakejackson1 force-pushed the security/deny-list-bypass branch 3 times, most recently from 816e9f9 to 39d32ad Compare August 21, 2026 18:48
`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
jakejackson1 force-pushed the security/deny-list-bypass branch from 3fe1691 to 7b35973 Compare August 21, 2026 19:03
@jakejackson1
jakejackson1 merged commit edef7fd into main Aug 24, 2026
34 checks passed
@jakejackson1
jakejackson1 deleted the security/deny-list-bypass branch August 24, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant