The remote avatar/media fetchers wrote temp files to storage/app/remcache/
and only unlinked them on the happy path. Any exception between the write
and the unlink (e.g. a cloud upload failure) leaked the file, and nothing
swept the directory.
- Wrap post-write logic in fetchAvatar() and remoteToCloud() in try/finally
so the temp file is always removed, even on failure
- Add gc:remcache command to delete stale remcache files (default >24h old,
preserves .gitignore, supports --hours and --dry-run)
- Schedule gc:remcache daily to clean up any stragglers
StoryFetch already handled cleanup via try/catch and was left unchanged.
The remote media path validated URLs only as strings (Helpers::validateUrl
normalizes the host + checks a ban list) and then downloaded them with
Http::head + file_get_contents($url), which resolve DNS themselves and
follow redirects with no private-IP checks and no address pinning. A remote
actor whose icon.url redirected to an internal address (e.g. 172.18.0.1 or
169.254.169.254) made the queue worker fetch internal content and, for
image responses, republish it at a public avatar URL. No account required.
Fixes:
- Add SecureMediaFetchService: validates URL, resolves + rejects non-global
IPs (fail-closed), pins the connection to the validated IP via
CURLOPT_RESOLVE, disables auto-redirects with per-hop re-validation, and
enforces https-only + a byte cap. Mirrors the ActivityPubFetchService
hardening from CVE-2026-71246.
- Route MediaStorageService head()/fetchAvatar()/remoteToCloud() through it,
removing the bare Http::head and file_get_contents($url) sinks.
- validateUrl(): when DNS verification is enabled, reject hosts that resolve
into reserved ranges, closing the metadata.google.internal bypass.
- Harden adjacent same-class sinks: CustomEmojiService (emoji doc + image +
head), FetchCacheService/webfinger, and DiscoverActor.
- Add regression tests (tests/Unit/ActivityPub/SsrfUrlValidationTest.php).
Move all Eloquent models from the app/ root directory to app/Models/
for consistency with modern Laravel conventions. The project already had
54 models in App\Models; this migrates the remaining 52 legacy models.
Changes:
- Move 52 model files from app/ to app/Models/
- Update namespace declarations in each model
- Update all ~1000 import references across the codebase
- Add Relation::morphMap() in AppServiceProvider for backward
compatibility with existing polymorphic database records
- Add missing HasSnowflakePrimary imports for models that relied
on same-namespace resolution
Posting is done in two steps - first the media is uploaded and that begins a processing pipeline. When the media is finally
processed, the media pipeline sets the cdn_url to the final image (in the cloud if configured).
At any time, the user can click post. This begins a separate NewStatusPipeline to deliver the status over ActivityPub.
This causes a race. If the NewStatusPipeline runs before the media finishes processing, then the status is delivered with
the un-processed (and un-optimized images). If pixelfed is set to use cloud storage, then the ActivityPub message also incorrectly
uses the local media.
This commit fixes the race condition by waiting for all the media to be processed before sending the message over ActivityPub.
A new publish_delayed column is set per-post, indicating whether the
status should be delayed until all media is processed.
Then, the NewStatusPipeline is called twice per post -
When the media finishes processing and when the status is posted.
Both times, the pipeline will check to see if it's valid to post
If so, the pipeline will try to set publish_delayed to false
and only if it is the first time doing so, will it publish the post