refactor: collapse media-attach helpers back into MediaAttacher
The previous split (MediaAttacher + MediaDownloader + UrlSafetyGuard interface + DnsUrlSafetyGuard impl + provider binding) was over-engineered for one production implementation. Roll it all back into a single MediaAttacher class and delegate to the existing `Workspace::addMediaFromPath` helper for storage + Media row creation (the same path the web upload flow uses). What's gone: - app/Services/Post/MediaDownloader.php - app/Services/Post/UrlSafetyGuard.php (interface) - app/Services/Post/DnsUrlSafetyGuard.php (impl) - tests/Unit/Services/Post/DnsUrlSafetyGuardTest.php - The provider bind in AppServiceProvider What's still defended: - Streaming download via Http::sink with a Guzzle progress callback that aborts mid-stream once MAX_BYTES is exceeded (no body buffered in PHP). - Redirects disabled so a 200→302 trick can't pivot to internal hosts. - IP-literal SSRF guard: rejects loopback / private / link-local / reserved ranges via FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE. DNS hostnames are accepted — finer SSRF (DNS rebinding etc.) is left to network-level egress controls. This is a deliberate simplification: the prior DNS resolution defense added a class + interface + provider binding for marginal gain when the realistic attack surface is hard-coded internal IPs. - Strict MIME allowlist (no SVG, no PDF, no application/*). - Lock-then-merge into post.media[] to avoid lost-update races. Tests bypass the SSRF check via the static `MediaAttacher::fakeUrlSafety()` called once in tests/TestCase::setUp — same idiom as Mail::fake / Bus::fake.
This commit is contained in:
parent
c871edf144
commit
c3b8af7189
7 changed files with 104 additions and 312 deletions
|
|
@ -37,8 +37,6 @@
|
|||
use App\Models\WorkspaceInvite;
|
||||
use App\Models\WorkspaceLabel;
|
||||
use App\Models\WorkspaceSignature;
|
||||
use App\Services\Post\DnsUrlSafetyGuard;
|
||||
use App\Services\Post\UrlSafetyGuard;
|
||||
use App\Services\PostTemplate\Registry as PostTemplateRegistry;
|
||||
use App\Socialite\InstagramProvider;
|
||||
use App\Socialite\LinkedInPageExtendSocialite;
|
||||
|
|
@ -83,7 +81,6 @@ class AppServiceProvider extends ServiceProvider
|
|||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(PostTemplateRegistry::class);
|
||||
$this->app->bind(UrlSafetyGuard::class, DnsUrlSafetyGuard::class);
|
||||
|
||||
if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) {
|
||||
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Post;
|
||||
|
||||
/**
|
||||
* Default UrlSafetyGuard. Rejects:
|
||||
* - non-http(s) schemes
|
||||
* - missing or empty hosts
|
||||
* - literal IPv4/IPv6 hosts in restricted ranges
|
||||
* - DNS hostnames whose A/AAAA records resolve into restricted ranges
|
||||
* (covers DNS-rebinding attempts where the first lookup is public and
|
||||
* subsequent lookups resolve internally)
|
||||
*/
|
||||
class DnsUrlSafetyGuard implements UrlSafetyGuard
|
||||
{
|
||||
public function isSafe(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
|
||||
if (! is_array($parts) || ! in_array(data_get($parts, 'scheme'), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = data_get($parts, 'host');
|
||||
|
||||
if (! is_string($host) || $host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return $this->ipIsPublic($host);
|
||||
}
|
||||
|
||||
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
||||
|
||||
if ($records === false || $records === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
|
||||
if (! is_string($ip) || ! $this->ipIsPublic($ip)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function ipIsPublic(string $ip): bool
|
||||
{
|
||||
return filter_var(
|
||||
$ip,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
) !== false;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,22 +5,38 @@
|
|||
namespace App\Services\Post;
|
||||
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Orchestrates "URL → media on a post" — given a list of public URLs,
|
||||
* download each via `MediaDownloader`, validate the MIME against the
|
||||
* post's enabled platforms, persist the file on the configured Storage
|
||||
* disk, and append a Media record to the post.
|
||||
* Downloads public URLs and attaches them as media to a post. Used by
|
||||
* both the MCP `AttachMediaFromUrlTool` and the REST `POST /api/posts/{post}/media`
|
||||
* endpoint.
|
||||
*
|
||||
* Used by both the MCP `AttachMediaFromUrlTool` and the REST
|
||||
* `POST /api/posts/{post}/media` endpoint so behaviour stays aligned.
|
||||
* Flow per URL:
|
||||
* 1. Reject the URL if its host is a literal IP in a restricted range
|
||||
* (loopback / private / link-local / reserved). DNS hostnames go
|
||||
* through; we trust the upstream firewall / egress controls for
|
||||
* finer-grained SSRF defense.
|
||||
* 2. Stream the body to a temp file via Http::sink + a progress
|
||||
* callback that aborts mid-download once MAX_BYTES is exceeded —
|
||||
* memory stays bounded.
|
||||
* 3. Validate the Content-Type against an allowlist (no SVG, no PDF)
|
||||
* AND the intersection of allowed media types across the post's
|
||||
* enabled platforms.
|
||||
* 4. Hand off to `Workspace::addMediaFromPath()` (the same helper the
|
||||
* web upload flow uses) so storage path, MIME re-detection, image
|
||||
* normalization, and the Media row stay in one place.
|
||||
* 5. Append the resulting media item to the post's `media[]` JSON
|
||||
* column under a row lock so concurrent attach calls don't clobber
|
||||
* each other.
|
||||
*
|
||||
* Tests bypass the SSRF check via `MediaAttacher::fakeUrlSafety()`
|
||||
* (called in tests/TestCase) so synthetic Http::fake hosts aren't
|
||||
* rejected.
|
||||
*/
|
||||
class MediaAttacher
|
||||
{
|
||||
|
|
@ -30,9 +46,17 @@ class MediaAttacher
|
|||
|
||||
private const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaDownloader $downloader,
|
||||
) {}
|
||||
private static bool $skipUrlSafety = false;
|
||||
|
||||
public static function fakeUrlSafety(): void
|
||||
{
|
||||
self::$skipUrlSafety = true;
|
||||
}
|
||||
|
||||
public static function resetUrlSafety(): void
|
||||
{
|
||||
self::$skipUrlSafety = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
|
|
@ -70,59 +94,87 @@ public function attachFromUrls(Post $post, array $urls): array
|
|||
*/
|
||||
private function processOne(Workspace $workspace, string $url, array $allowedTypes): ?array
|
||||
{
|
||||
$download = $this->downloader->download($url, self::MAX_BYTES);
|
||||
|
||||
if ($download === null) {
|
||||
if (! $this->isUrlSafe($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'media_');
|
||||
|
||||
try {
|
||||
$type = $this->resolveType(data_get($download, 'mime'));
|
||||
$response = Http::timeout(20)
|
||||
->sink($temp)
|
||||
->withOptions([
|
||||
'allow_redirects' => false,
|
||||
'progress' => static function ($total, $downloaded): void {
|
||||
if ($downloaded > self::MAX_BYTES) {
|
||||
throw new RuntimeException('exceeded max bytes');
|
||||
}
|
||||
},
|
||||
])
|
||||
->get($url);
|
||||
|
||||
if (! $response->successful() || filesize($temp) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = trim(explode(';', (string) $response->header('Content-Type'))[0]);
|
||||
$type = $this->resolveType($mime);
|
||||
|
||||
if ($type === null || ! in_array($type, $allowedTypes, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->storeMedia($workspace, $download, $type, $url);
|
||||
$originalFilename = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin';
|
||||
$media = $workspace->addMediaFromPath($temp, $originalFilename, 'assets');
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => $media->type,
|
||||
'mime_type' => $media->mime_type,
|
||||
'original_filename' => $media->original_filename,
|
||||
];
|
||||
} catch (RuntimeException) {
|
||||
return null;
|
||||
} finally {
|
||||
@unlink(data_get($download, 'path'));
|
||||
@unlink($temp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path: string, mime: ?string, bytes: int} $download
|
||||
* @return array<string, mixed>
|
||||
* Reject obvious SSRF targets: non-http(s) schemes, missing host,
|
||||
* and IP-literal hosts in private / loopback / link-local / reserved
|
||||
* ranges. DNS hostnames are accepted — finer-grained protection
|
||||
* (DNS rebinding, etc.) is left to network-level controls.
|
||||
*/
|
||||
private function storeMedia(Workspace $workspace, array $download, MediaType $type, string $url): array
|
||||
private function isUrlSafe(string $url): bool
|
||||
{
|
||||
$mime = data_get($download, 'mime');
|
||||
$extension = $this->extensionFor($mime, $url);
|
||||
$filename = 'media/'.Str::uuid()->toString().'.'.$extension;
|
||||
$originalFilename = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.'.$extension;
|
||||
if (self::$skipUrlSafety) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Storage::putFileAs('', new File(data_get($download, 'path')), $filename);
|
||||
$parts = parse_url($url);
|
||||
|
||||
$media = new Media([
|
||||
'collection' => 'post-media',
|
||||
'type' => $type,
|
||||
'path' => $filename,
|
||||
'original_filename' => $originalFilename,
|
||||
'mime_type' => $mime ?? '',
|
||||
'size' => data_get($download, 'bytes'),
|
||||
'order' => 0,
|
||||
]);
|
||||
$media->mediable_type = Workspace::class;
|
||||
$media->mediable_id = $workspace->id;
|
||||
$media->save();
|
||||
if (! is_array($parts) || ! in_array(data_get($parts, 'scheme'), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => $type->value,
|
||||
'mime_type' => $media->mime_type,
|
||||
'original_filename' => $media->original_filename,
|
||||
];
|
||||
$host = data_get($parts, 'host');
|
||||
|
||||
if (! is_string($host) || $host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return filter_var(
|
||||
$host,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
) !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -172,7 +224,7 @@ private function allowedMediaTypesFor(Post $post): array
|
|||
|
||||
private function resolveType(?string $mime): ?MediaType
|
||||
{
|
||||
if ($mime === null) {
|
||||
if ($mime === null || $mime === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -186,28 +238,4 @@ private function resolveType(?string $mime): ?MediaType
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extensionFor(?string $mime, string $url): string
|
||||
{
|
||||
$byMime = match ($mime) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/quicktime' => 'mov',
|
||||
'video/webm' => 'webm',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($byMime) {
|
||||
return $byMime;
|
||||
}
|
||||
|
||||
$byUrl = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($byUrl, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'mov', 'webm'], true)
|
||||
? $byUrl
|
||||
: 'bin';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Post;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Downloads a single media file from a public URL into a local temporary
|
||||
* file, streaming the body to disk so we don't buffer it in PHP memory.
|
||||
* Aborts mid-stream once `maxBytes` is exceeded.
|
||||
*
|
||||
* The caller owns the temp file lifecycle — receive `path`, do whatever
|
||||
* (validate / upload to Storage), then delete it.
|
||||
*/
|
||||
class MediaDownloader
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlSafetyGuard $guard,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{path: string, mime: ?string, bytes: int}|null
|
||||
* null when the URL is unsafe, the response failed, or the
|
||||
* download exceeded `maxBytes`.
|
||||
*/
|
||||
public function download(string $url, int $maxBytes): ?array
|
||||
{
|
||||
if (! $this->guard->isSafe($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'media_');
|
||||
|
||||
try {
|
||||
$response = Http::timeout(20)
|
||||
->sink($temp)
|
||||
->withOptions([
|
||||
'allow_redirects' => false,
|
||||
'progress' => static function ($total, $downloaded) use ($maxBytes): void {
|
||||
if ($downloaded > $maxBytes) {
|
||||
throw new RuntimeException('exceeded max bytes');
|
||||
}
|
||||
},
|
||||
])
|
||||
->get($url);
|
||||
} catch (RuntimeException) {
|
||||
@unlink($temp);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
@unlink($temp);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = filesize($temp) ?: 0;
|
||||
|
||||
if ($bytes === 0 || $bytes > $maxBytes) {
|
||||
@unlink($temp);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = $response->header('Content-Type');
|
||||
$mime = $mime ? trim(explode(';', $mime)[0]) : null;
|
||||
|
||||
return [
|
||||
'path' => $temp,
|
||||
'mime' => $mime,
|
||||
'bytes' => $bytes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Post;
|
||||
|
||||
/**
|
||||
* Decides whether a user-supplied URL is safe to fetch from the server side.
|
||||
* The default implementation (DnsUrlSafetyGuard) blocks loopback / private /
|
||||
* link-local / reserved IP ranges to prevent SSRF; tests bind a permissive
|
||||
* implementation so synthetic hosts under Http::fake() aren't rejected.
|
||||
*/
|
||||
interface UrlSafetyGuard
|
||||
{
|
||||
public function isSafe(string $url): bool;
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace Tests;
|
||||
|
||||
use App\Services\Post\UrlSafetyGuard;
|
||||
use App\Services\Post\MediaAttacher;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
|
|
@ -24,16 +24,8 @@ protected function setUp(): void
|
|||
|
||||
$this->withoutVite();
|
||||
|
||||
// Bypass the DNS-based SSRF guard during tests. Feature tests use
|
||||
// Http::fake() with synthetic hosts (e.g. cdn.example.com) that
|
||||
// wouldn't resolve to public IPs; the real guard is exercised by
|
||||
// tests/Unit/Services/Post/DnsUrlSafetyGuardTest in isolation.
|
||||
$this->app->bind(UrlSafetyGuard::class, fn () => new class implements UrlSafetyGuard
|
||||
{
|
||||
public function isSafe(string $url): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// Bypass the SSRF check during tests so Http::fake() with
|
||||
// synthetic hosts like cdn.example.com isn't rejected.
|
||||
MediaAttacher::fakeUrlSafety();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Post\DnsUrlSafetyGuard;
|
||||
|
||||
beforeEach(function () {
|
||||
// The TestCase setUp binds a permissive UrlSafetyGuard for feature
|
||||
// tests; this unit test exercises the real DnsUrlSafetyGuard directly,
|
||||
// so we instantiate it instead of resolving from the container.
|
||||
$this->guard = new DnsUrlSafetyGuard;
|
||||
});
|
||||
|
||||
test('rejects non-http(s) schemes', function () {
|
||||
expect($this->guard->isSafe('ftp://example.com/file.zip'))->toBeFalse();
|
||||
expect($this->guard->isSafe('file:///etc/passwd'))->toBeFalse();
|
||||
expect($this->guard->isSafe('javascript:alert(1)'))->toBeFalse();
|
||||
expect($this->guard->isSafe('gopher://example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects malformed URLs', function () {
|
||||
expect($this->guard->isSafe('not-a-url'))->toBeFalse();
|
||||
expect($this->guard->isSafe('http://'))->toBeFalse();
|
||||
expect($this->guard->isSafe(''))->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects IPv4 hosts in restricted ranges', function () {
|
||||
// Loopback
|
||||
expect($this->guard->isSafe('http://127.0.0.1/'))->toBeFalse();
|
||||
expect($this->guard->isSafe('http://127.255.255.254/'))->toBeFalse();
|
||||
|
||||
// RFC1918 private
|
||||
expect($this->guard->isSafe('http://10.0.0.1/'))->toBeFalse();
|
||||
expect($this->guard->isSafe('http://172.16.0.1/'))->toBeFalse();
|
||||
expect($this->guard->isSafe('http://192.168.1.1/'))->toBeFalse();
|
||||
|
||||
// Link-local + AWS metadata endpoint
|
||||
expect($this->guard->isSafe('http://169.254.169.254/latest/meta-data'))->toBeFalse();
|
||||
|
||||
// Reserved zero / broadcast
|
||||
expect($this->guard->isSafe('http://0.0.0.0/'))->toBeFalse();
|
||||
expect($this->guard->isSafe('http://255.255.255.255/'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects IPv6 hosts in restricted ranges', function () {
|
||||
// Loopback
|
||||
expect($this->guard->isSafe('http://[::1]/'))->toBeFalse();
|
||||
|
||||
// Unique local (fc00::/7)
|
||||
expect($this->guard->isSafe('http://[fd00::1]/'))->toBeFalse();
|
||||
|
||||
// Link-local (fe80::/10)
|
||||
expect($this->guard->isSafe('http://[fe80::1]/'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('accepts a literal public IPv4', function () {
|
||||
// 1.1.1.1 (Cloudflare DNS) is a stable public IP we can hard-code.
|
||||
expect($this->guard->isSafe('http://1.1.1.1/'))->toBeTrue();
|
||||
expect($this->guard->isSafe('https://8.8.8.8/'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('accepts a hostname that resolves to public IPs', function () {
|
||||
// example.com is reserved by IANA for documentation; resolves stably
|
||||
// and lives at public IPs.
|
||||
expect($this->guard->isSafe('https://example.com/'))->toBeTrue();
|
||||
})->skip(getenv('CI') === 'true', 'depends on outbound DNS, skipped on CI');
|
||||
|
||||
test('rejects a hostname with no DNS records', function () {
|
||||
// .invalid is reserved (RFC 2606) — guaranteed not to resolve.
|
||||
expect($this->guard->isSafe('http://does-not-exist.invalid/'))->toBeFalse();
|
||||
})->skip(getenv('CI') === 'true', 'depends on DNS resolution behavior, skipped on CI');
|
||||
Loading…
Reference in a new issue