trypost/app/Services/Post/MediaAttacher.php
Paulo Castellano 4a08913d70 fix(security): make SSRF private-network block configurable and guard the last user-URL fetches
Add config('trypost.security.allow_private_network') (env TRYPOST_ALLOW_PRIVATE_NETWORK, default off) so self-hosted operators can reach their own internal network; only the private-IP rejection is bypassed, scheme/host checks always apply. Add SafeHttpFetcher::guardedRequest() and route the last unguarded user-supplied-URL fetches through it: the Unsplash/Giphy asset import, the API/MCP attach-media-from-URL download, and the OAuth avatar download. Our-own-storage reads (media crop, Bluesky media) are intentionally left unguarded so internal storage keeps working when self-hosted.
2026-07-17 15:40:15 -03:00

196 lines
6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Post;
use App\Enums\Media\Type as MediaType;
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use App\Services\Brand\SafeHttpFetcher;
use RuntimeException;
use Throwable;
/**
* Downloads public URLs and attaches them as media to a post — used by
* the MCP `AttachMediaFromUrlTool` and the REST `attach-media-from-url`
* endpoint.
*
* URL syntax + DNS resolvability are validated at the request layer
* (`url:http,https`, `active_url`). Locking, intersection of accepted
* media types, and the JSON-column merge live on the Post model so this
* class can stay focused on the network → MIME-validate → handoff flow.
*/
class MediaAttacher
{
public function __construct(private readonly SafeHttpFetcher $safeHttp) {}
/**
* @param array<int, array{url: string, alt?: ?string}> $urls
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
*/
public function attachFromUrls(Post $post, array $urls): array
{
$attached = [];
$failed = [];
foreach ($urls as $entry) {
$url = (string) data_get($entry, 'url', '');
$item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url);
if ($item === null) {
$failed[] = $url;
continue;
}
if (($alt = data_get($entry, 'alt')) !== null
&& MediaType::classify(data_get($item, 'mime_type'), data_get($item, 'path')) === MediaType::Image) {
$item['meta'] = ['alt_text' => $alt];
}
$attached[] = $item;
}
if ($attached !== []) {
$post->appendMedia($attached);
}
return ['attached' => $attached, 'failed' => $failed];
}
/**
* Resolve an inline media array into hosted items: items with a `path` pass
* through, external URLs are downloaded and hosted. Atomic — when any item
* fails, media hosted in this call is rolled back so nothing is orphaned.
*
* @param array<MediaType> $allowedTypes
* @param array<int, array<string, mixed>> $items
* @return array{media: array<int, array<string, mixed>>, failed: array<int, string>}
*/
public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, array $items): array
{
$media = [];
$failed = [];
$hostedIds = [];
foreach ($items as $item) {
if (filled(data_get($item, 'path'))) {
$media[] = $item;
continue;
}
$url = (string) data_get($item, 'url', '');
$hosted = $this->fetchToWorkspace($workspace, $allowedTypes, $url);
if ($hosted === null) {
$failed[] = $url;
continue;
}
if (($meta = data_get($item, 'meta')) !== null) {
$hosted['meta'] = $meta;
}
$media[] = $hosted;
$hostedIds[] = data_get($hosted, 'id');
}
if ($failed !== [] && $hostedIds !== []) {
Media::query()->whereKey($hostedIds)->get()->each->delete();
}
return ['media' => $media, 'failed' => $failed];
}
/**
* Download a URL, validate its type, and store it on the workspace.
*
* @param array<MediaType> $allowedTypes
* @return array<string, mixed>|null
*/
private function fetchToWorkspace(Workspace $workspace, array $allowedTypes, string $url): ?array
{
$download = $this->download($url);
if ($download === null) {
return null;
}
try {
$type = MediaType::fromMime($download['mime'] ?? '');
if ($type === null || ! in_array($type, $allowedTypes, true)) {
return null;
}
if ($download['bytes'] > $type->maxSizeInBytes()) {
return null;
}
$name = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin';
$media = $workspace->addMediaFromPath($download['path'], $name, 'assets');
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
];
} finally {
@unlink($download['path']);
}
}
/**
* Stream the URL to a temp file, aborting once we exceed the largest
* configured per-type cap (video). MIME is sniffed from the file's
* magic bytes — far more reliable than trusting the upstream
* `Content-Type` header (CDNs misconfigure, attackers spoof).
*
* @return array{path: string, mime: ?string, bytes: int}|null
*/
private function download(string $url): ?array
{
$cap = MediaType::Video->maxSizeInBytes();
$temp = tempnam(sys_get_temp_dir(), 'media_');
try {
$response = $this->safeHttp->guardedRequest($url, followRedirects: false)
->timeout(20)
->sink($temp)
->withOptions([
'progress' => static function ($total, $downloaded) use ($cap): void {
if ($downloaded > $cap) {
throw new RuntimeException('exceeded max bytes');
}
},
])
->get($url);
} catch (Throwable) {
// Any fetch failure (timeout, DNS, refused, oversize) is a failed download.
@unlink($temp);
return null;
}
$bytes = filesize($temp) ?: 0;
if (! $response->successful() || $bytes === 0) {
@unlink($temp);
return null;
}
return [
'path' => $temp,
'mime' => mime_content_type($temp) ?: null,
'bytes' => $bytes,
];
}
}