Skip to content

Resolve findings from the security hardening audit - #20

Open
jakejackson1 wants to merge 8 commits into
mainfrom
hardening
Open

Resolve findings from the security hardening audit#20
jakejackson1 wants to merge 8 commits into
mainfrom
hardening

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Aug 4, 2026

Copy link
Copy Markdown
Member

The 4.0.0 security hardening release, with the findings from a follow-up audit of the work folded in.

Storage writes each upload to a staged file and moves it into place rather than writing to the destination directly, applies a default extension deny-list, and refuses traversal, dotfiles, control and bidi characters, Windows device names and symlinked destinations. Validation\FileType pairs an extension with the media type sniffed from the contents. Filenames and extensions are sanitized, including in the strings getErrors() returns, stored files get mode 0640, and upload() now requires something to validate against.

Five things to know before merging

  • Defaults changed and can reject uploads 3.x accepted. UPGRADE.md is the step-by-step guide.
  • The deny-list refuses a file whose own extension is on it, markup such as .html and .svg included. UPGRADE.md has the snippets to narrow or replace the list. Dots inside the name are not extension separators — FileInfo rewrites them to hyphens, so release.config.zip is stored as release-config.zip. A deny-list entry that itself contains dots is split into components, so blockExtensions(['tar.gz']) blocks both tar and gz where before it blocked nothing.
  • Two size units changed meaning. '5MB' parsed as 5 bytes and now parses as 5 MiB, so any MB/KB/GB suffixed limit becomes much larger. A unit outside B/K/M/G now throws instead of being read as bytes — '1T' was a one byte bound that rejected every upload while reading as a generous one. Check every Validation\Size bound you configure.
  • Two developer errors changed exception type. File::upload() throws \LogicException with no validations configured, and FileInfo::getHash() throws \InvalidArgumentException for an unsupported algorithm. Both were Upload\Exception — the type isValid() catches and formats into getErrors(), so a misspelled algorithm reached the end user as a rejected file. Code catching Upload\Exception specifically around upload() needs \LogicException too; the catch (\Exception $e) the README shows is unaffected.
  • One exception message changed. 'File already exists' now names the file that is in the way, because sanitizing is many-to-one and the old wording could not say which name collided. Update anything matching on the old string, and log storage messages rather than showing them to whoever submitted the file — the wording distinguishes a name that exists from a destination that could not be created.

If you implement FileInfoInterface yourself, note that its three setters no longer declare a return type. They declared : FileInfo, the concrete class, so an implementation that did not extend FileInfo satisfied the compiler and then raised a TypeError on the first setter call — a custom FileInfoInterface is only now actually implementable. StorageInterface::upload()'s return value is specified for the first time as well: a locator your storage defines, which is why the README's unlink() rollback is right for FileSystem and not necessarily for yours.

The audit

Seven independent reviewers over 0a8b71d produced 20 findings and 4 untested failure branches. All are resolved in the six commits that follow it. The two that mattered most:

  • The README claimed beforeUpload could not dodge validation. It runs after validation, so a name set there is never validated — only the storage deny-list and FileSystem's filename rules apply. Corrected, with a test pinning the hook order.
  • FileInfoInterface's setters, above. The interface had never been implementable by anything that was not a FileInfo, despite this library documenting it as an extension point.

Each fix carries a test that was verified to fail against the code before it, including the four failure branches, which were checked by deleting the branch and watching the test go red.

Full detail in CHANGELOG.md, UPGRADE.md and the README.

@jakejackson1
jakejackson1 force-pushed the hardening branch 4 times, most recently from f5307d5 to a3e2bc7 Compare August 4, 2026 01:33
jakejackson1 added a commit to GravityPDF/gravity-pdf that referenced this pull request Aug 14, 2026
Five changes to how PDF templates get installed.

Multiple zips can now be selected or dropped together. Previously only
the first survived: the saga used takeLatest, which cancelled every
in-flight upload but the last, and the reducer held a single
success/error object that concurrent results overwrote. Uploads now use
takeEvery, results carry the filename they belong to and are appended to
a templateUploadResults array, and the component drains that array with
a batch counter so results arriving in one React render can't be lost.
Each file reports its own outcome.

Zips with the templates nested inside a folder now install. Safari
auto-extracts a template zip on download; users then re-zip the folder,
which buries the PHP files one level deep where the non-recursive
get_all_templates_in_folder() couldn't see them. Helper_Templates now
descends through single-directory wrappers to find the templates.
Multiple directories in the root stay invalid, as #1336 specified.

The upload limit goes from 10MB to 32MB, clamped by wp_max_upload_size().
The clamp matters because a POST over post_max_size is discarded by PHP
before the request reaches us, which surfaced as a nonce failure rather
than a size error. One constant now feeds the server-side validator, the
JS pre-flight check and the error message, which reports the real limit
via size_format().

The drop target is the whole Template Manager window instead of the tile
at the foot of the list. TemplateUploader wraps the manager with
noClick/noKeyboard and shares the file picker with the "Add New Template"
tile through context. Dragging anywhere shows a full-viewport overlay,
and progress and results appear in a toast pinned to the modal so they're
visible wherever the list is scrolled.

The bundled upload library moves to 4.0, a security-hardening release. Storage
stages each upload and moves it into place rather than writing to the
destination directly, refuses traversal, dotfiles, control characters and
symlinked destinations, applies a default extension deny-list, and stores files
as 0640. Template zips move from Extension + Mimetype to the new
Validation\FileType: the old pair checked two independent allow-lists, so the
extension and the sniffed contents never had to describe the same format.
The octet-stream allowance is kept, because plenty of servers report a zip that
way. GFPDF\Helper\Fonts\LocalFile overrides isValid() wholesale to skip the
is-uploaded-file check, so it did not inherit 4.0's reset of the error list --
without it, upload() calling isValid() again reported every font validation
error twice.

Note for review: composer.json points at dev-hardening while
GravityPDF/Upload#20 is open. It needs repointing at ^4.0 once that is tagged,
before this can merge.

Closes #1336
Closes #1337

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1

This comment was marked as outdated.

Correct the deny-list documentation and pin it with a test

The upgrade guide, changelog and README all stated that a blocked word
anywhere between dots is refused, giving `release.config.zip` as an
example. That is wrong: `FileInfo::setName()` has always rewritten
interior dots to hyphens, so the name reaching storage carries a single
extension and is stored as `release-config.zip`.

The every-component check in `FileSystem::upload()` is real, but it is a
backstop for a caller-supplied `FileInfoInterface`, which is how the
existing tests reach it. Say that instead, and re-anchor the
narrow-the-list example to a name that genuinely refuses.

Adds the test that would have caught the claim, driving a real `FileInfo`
through `upload()` rather than the hostile stub. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Close two gaps in reserved-name and reserved-character handling

The Windows device-name list stopped at COM9/LPT9. Microsoft's list also
carries COM0, LPT0 and a superscript twin of each single digit, each of
which resolves to the same device, so `COM0.txt` and `COM<U+00B9>.txt`
were stored unchanged. The list moves to the public constant
`FileInfo::RESERVED_WINDOWS_NAMES`; the protected accessor stays, so an
override still works.

`FileSystem::resolveFilename()` screened control characters but not the
rest of the set Windows disallows, and did not screen device names at
all. Both now refuse, matching that layer's existing stance of rejecting
rather than rewriting. `:` is the one that fails unhelpfully rather than
loudly: on NTFS it names an alternate data stream instead of erroring.

Neither is reachable through the shipped `FileInfo`, which rewrites or
blanks all of it first. Both are about the `FileInfoInterface` extension
point, which is what that layer exists to distrust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Rewrite Windows-disallowed characters rather than refusing them

POSIX allows `< > : " | ? *` in a filename, so refusing them rejected
names that are perfectly ordinary on the system doing the storing. They
are replaced with `-` instead, matching how `FileInfo` treats the same
set one layer up.

The rewrite runs before the deny-list and the device-name check, so those
see the name that will actually be written and a blocked extension cannot
ride in behind one of these characters.

Reserved device names still refuse: `CON.txt` is a whole name rather than
a character, and there is nothing to rewrite it to that the caller would
recognise as their file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Simplify the Windows reserved-name checks and their tests

`refuseReservedWindowsNames()` used `strstr($filename . '.', '.', true)`,
where the appended dot dodged a `false` return and the cast that followed
dodged PHPStan's complaint about the same `false`. Two workarounds for a
return type the code never wanted. `explode('.', $filename, 2)[0]` needs
neither, and matches how `refuseBlockedExtensions()` already splits the
name, so the two checks cannot disagree about where the first component
ends. `trim()` narrows to spaces, which is the rule Windows actually
applies rather than whatever bytes bare `trim()` happens to cover.

The device-name test was a byte-identical clone of
`testRejectsUnusableFileNames`, asserting the same exception and message
from a second provider; its cases move into `providerUnusableFileNames`
beside the other names storage refuses. Three tests that each rebuilt the
same working-directory-upload-assert body now share `assertStoredAs()`.

Docblocks lose a rationale that had been copied to four sites and now
lives once on the constant, and `getReservedWindowsNames()` says why it
survives as a one-line wrapper.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Name the file in the collision message

Sanitizing is many-to-one, in `FileInfo` as well as at the storage layer:
`report?.txt` and `report*.txt` both resolve to `report-.txt`, and
`my+file.txt` and `my%20file.txt` have always both resolved to
`my-file.txt`. A caller told only `'File already exists'` cannot tell
which of their names collided, or that two of them collided with each
other rather than with something already on disk.

The message becomes `'A file named "report-.txt" already exists'`. The
basename only, never the path, and it has been through
`resolveFilename()` already, so it carries no separators or control
characters.

Callers matching on the old string need updating; noted in the changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Sanitize the name a custom FileInfoInterface puts into getErrors()

`File::isValid()` formatted `getNameWithExtension()` straight into three
error strings. Through the shipped `FileInfo` that name has already been
sanitized, but `FileInfoInterface` is a public extension point and
`FileInfo::setFactory()` lets any code in the process supply one, so the
value was whatever that implementation returned — a line break forging a
log line, a terminal escape, a bidi override reversing how the name
reads. `getErrors()` is the one thing the library documents as safe to
show an end user.

`uploadErrorMessage()` already solved this for the `$_FILES` path; the
new `safeName()` applies the same treatment, and for the same reason uses
`new FileInfo()` rather than the factory a caller can replace.

Storage already refused to trust a `FileInfoInterface`; this brings
`File` into line. Sanitizing is still not escaping, and output still
needs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 marked this pull request as ready for review August 19, 2026 01:00
jakejackson1 and others added 7 commits August 19, 2026 11:36
01  The README claimed renaming in `beforeUpload` could not dodge
    validation. `upload()` validates and only then enters the loop that
    fires the hook, so a name set there is never validated and only the
    storage deny-list constrains it. The claim is replaced with what
    actually happens, and points at `beforeValidate` for callers who need
    the final name validated. A test now pins the ordering.

02  `FileInfoInterface`'s three setters declared `: FileInfo`, so an
    implementation that did not extend `FileInfo` compiled and then threw
    `TypeError` on the first setter call. It could not narrow its own
    return type either — covariant returns arrived in 7.4 and this
    library supports 7.3. The interface now declares no return type;
    `FileInfo` keeps its own.

03  The reserved Windows device name check ran before the encoding
    repair, so `con\xC3.txt` was not `con` when the check looked and was
    `con.txt` afterwards. It now runs after.

04  Both control-character filters covered C0 only. The C1 range is valid
    UTF-8, so `FileInfo` kept it and the storage layer accepted it —
    U+0085 ends a line for anything matching on `\R`. Both sites now
    match `\xC2[\x80-\x9F]`.

05  `humanReadableToBytes()` read an unrecognised unit as bytes, so
    `Size('1T')` was a one byte bound that rejected every upload. The
    pattern now admits only B/K/M/G and anything else takes the existing
    throw. This changes a behaviour 3.x pinned deliberately.

06  `strtolower()` follows `LC_CTYPE` before PHP 8.2, so a Turkish locale
    turned `photo.TIFF` into an extension `setExtension()` discarded. The
    thirteen folds that expect ASCII go through a new `AsciiCase` trait.

07  `getUploadedFiles()` was reset after `upload()`'s guards, so a call
    that failed validation handed back an earlier successful call's paths
    and the documented rollback loop would delete them.

08  The 255 byte budget was divided against the extension set when
    `setName()` ran, so a later `setExtension()` overshot by its length.
    The fitting step is now `fitToLimit()` and `setExtension()` redoes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings 09 through 15.

09  A reservation whose stat does not answer is reported as a failed create
    rather than as a symbolic link, and takes its own placeholder back. Left
    behind, it held the caller's name against every later upload.

10  A reservation that followed a symbolic link removes the file `x` mode
    created at the far end. Inode-verified, so a link re-pointed in between
    cannot make it delete a bystander; the link itself is left alone.

11  A developer error is no longer thrown as the type callers catch for a
    failed upload. `File::upload()` throws LogicException with no validations
    configured, `FileInfo::getHash()` throws InvalidArgumentException for an
    unsupported algorithm.

12  `blockExtensions()` splits a compound entry into its components. The list
    is matched one component at a time, so `tar.gz` was accepted and blocked
    nothing. Empty and duplicate entries are dropped.

13  The single-file constructor branch guards a non-string `tmp_name`/`name`,
    as the multi-file branch already did.

14  The class name of an absorbed throwable no longer reaches `getErrors()`,
    which defeated the sanitizing already on that line.

15  `afterValidate` fires for a file that fails the uploaded-file check, so
    the pair a caller opens and closes a resource with cannot leak.

Adds `statEntry()` as a seam for 09's branch, which is otherwise reachable
only by racing the file system, and `ExposedFileSystem` for 10's, which
`upload()`'s own `is_link()` check rejects before the reservation runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntry

Found while testing finding 13, which covered the single-file branch only.

The multi-file branch checked `tmp_name` and `name` per file but assumed
`name` and `error` were arrays of the same length as `tmp_name`. They are,
from the SAPI. From a PSR-7 bridge or a test harness they need not be, and
indexing them is worse than useless: an `error` that is an int warns with
"Trying to access array offset on int", and a `name` that is a string yields
a single character, which passes the per-file check, so the file is stored
under a one-letter name with nothing reported.

Both keys are now reduced to `[]` unless they are arrays, and the per-file
check covers the error code as well, so a bad entry costs only itself while
well-formed files in the same request are still collected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings 16 through 20.

16  The `upload()` docblock claimed the file appears under its final name
    only once complete. True with `overwrite = true`; with the default it is
    preceded by the placeholder that claims the name. Docblock corrected, and
    the placeholder now carries the configured mode rather than sitting at
    the umask for the whole transfer.

17  Documented that a storage exception message is for logs. It distinguishes
    "already exists" from "could not be created", which is an existence check
    on the upload directory. Kept the split, which is a real usability win.

18  `StorageInterface::upload()` specifies its return value: a locator the
    implementation defines, an absolute path only for `FileSystem`. Its
    `@throws` said "If validation fails", which storage does not do. The
    README's `unlink()` rollback is qualified to match.

19  Added `allowAnyExtension()`, `getBlockedExtensions()` and `getMode()`.
    `blockExtensions()`'s polarity locks at 4.0 — absent means the full list,
    empty means none — so the additive counterparts land now.

20  `FileInfo::BIDI_CONTROLS` is the whole of Unicode's Bidi_Control property
    plus the zero-width marks, separators and BOM, so U+061C and U+206A-206F
    join the set. `FileSystem::resolveFilename()` refuses a name still
    carrying one, which only a custom FileInfoInterface can produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One of the four test gaps the audit lists. Every existing test passed an
already-lowercase list, so nothing proved blockExtensions(['PHP']) still
refuses shell.php.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each was verified by removing the branch and watching the new test fail.

Validation\Size's unmeasurable-file branch. Added this release so one file
that no longer stats records an error against itself instead of aborting the
batch through SplFileInfo::getSize(), and SizeTest.php was not touched at
all. A missing path reaches it without mocking, since FileInfo::getSize()
already returns false for one.

The @rename() failure branch. The last step of the staged-write path the
symlink story rests on. Driven with setMode(null) so the @chmod two lines
above does not fail first, and asserts the reservation placeholder goes too
— left behind it holds the name against every later upload of it.

discard() under overwrite = true. Every failure-path test built storage with
overwriting off, so the guard that stops a failed upload deleting the
caller's existing file had no coverage; dropping the conditional passed CI.
Covered from both sides, since the same conditional has to clear the
placeholder when overwriting is off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six reviewers over the branch. One real defect, the rest documentation
that overstated or misdescribed what the code does.

File::__construct() still warned, and on PHP 8 fatally errored, on the very
shapes the finding-13 guard was added for. The guard checked the values but
dereferenced $_FILES[$key] and ['tmp_name'] before establishing they exist,
so a string entry was an uncaught TypeError; and the single-file branch never
type-checked the error code, so an array there reported the file as exceeding
upload_max_filesize, because (int) [0] is 1. Seven shapes now covered.

RESERVED_WINDOWS_NAMES lost its docblock when BIDI_CONTROLS was inserted
between it and its documentation. Reflection returned false for it; the
Microsoft link and the @var read as documenting a regex.

Two comment rationales were fiction, both verified by execution:

  forceValidUtf8() is not repairing sequences this class broke. Every
  rewrite is single-byte ASCII except \xC2[\x80-\x9F], which matches both
  bytes as a unit -- 0 of the code points U+0080-U+2FFF produce invalid
  UTF-8 through the chain. mb_strcut() cuts on a boundary, and the substr()
  fallback runs only when mbstring is absent, which is when the repair is
  skipped. It exists for names that arrive invalid, which is its second
  sentence.

  The mb_detect_order() ValueError is unreachable: mb_strcut() accepts every
  encoding mb_detect_encoding() can return.

Also corrected: humanReadableToBytes()'s docblock still promised the
behaviour finding 05 removed; Size's "aborts the whole batch" predates the
Throwable catch that absorbs it; Size's "must be less than" described an
inclusive bound as exclusive; blockExtensions() documented leading-dot
removal but not the splitting; the inode comparison is now marked POSIX-only,
since Windows before 7.4 reports ino as 0 and the check degrades to
same-drive; and "five of the eight supported PHP versions" is four.

README: getHash() named the exception type it was changed away from,
setExtension() was described as rejecting uppercase when it folds case
first and does not mention discarding device names, Exception was said to
extend \Exception rather than \RuntimeException, upload()'s empty-collection
throw was missing, the 255-byte limit is shared with the extension, and a
cross-reference pointed above at a table 120 lines below.

CHANGELOG: three claims about 3.x were actually about this branch's own
unreleased intermediate state -- neither upload() nor getHash() ever threw
Upload\Exception in 3.x, and the photo.TIFF outcome is 4.0's stricter
setExtension(), not a 3.x bug. Plus a duplicated getHash bullet, a
self-contradiction about the placeholder, and "removes" where C1 and DEL are
rewritten to a hyphen.

UPGRADE: told interface implementers that returning $this "now works" while
omitting the step that makes it work -- deleting : FileInfo from their own
setters, without which the TypeError is unchanged. Invented an Exception
subclass migration PHP does not require. Claimed report.txt. is stored as
report.txt when the shipped FileInfo makes it report-txt. Missed the
collision-message string change, the only bolded caller action in the
changelog with no route into the guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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