refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution and a static fakeUrlSafety() flag for tests. Validating URLs is a request-layer concern, not a service-layer one. Laravel ships 'active_url' which does the same DNS resolvability check via dns_get_record — applying it at the FormRequest / MCP validate() level catches dead URLs upfront with a proper 422 instead of letting the download silently fail. - Replace the inline 'urls.*' => ['url:http,https'] rule with ['url:http,https', 'active_url'] in both Api/PostController::attachMedia and Mcp/Tools/Post/AttachMediaFromUrlTool. - Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety from MediaAttacher. The remaining defenses (Http::sink streaming + progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist) cover the operational concerns. - Restore tests/TestCase to the original setUp — no SSRF bypass needed anymore because active_url is satisfied by the test hosts. - Swap synthetic test hosts (cdn.example.com / evil.example.com) for example.com / example.org. Both are RFC-reserved AND have stable A records, so active_url accepts them while Http::fake() still intercepts the actual request. For SSRF defense beyond 'active_url' (which doesn't block private IPs), trypost relies on production network egress controls. Open-source self-hosters who run without a firewall accept the corresponding risk; that's a deployment concern, not a request validation concern.
This commit is contained in:
parent
bc581f54f2
commit
4892ee75a5
6 changed files with 35 additions and 97 deletions
|
|
@ -88,7 +88,7 @@ public function attachMedia(Request $request, Post $post): PostMediaAttachResour
|
|||
|
||||
$validated = $request->validate([
|
||||
'urls' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'urls.*' => ['url:http,https'],
|
||||
'urls.*' => ['url:http,https', 'active_url'],
|
||||
]);
|
||||
|
||||
$result = app(MediaAttacher::class)->attachFromUrls($post, $validated['urls']);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
$validated = $request->validate([
|
||||
'post_id' => ['required', 'uuid'],
|
||||
'urls' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'urls.*' => ['url:http,https'],
|
||||
'urls.*' => ['url:http,https', 'active_url'],
|
||||
]);
|
||||
|
||||
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
|
||||
|
|
|
|||
|
|
@ -16,51 +16,33 @@
|
|||
* both the MCP `AttachMediaFromUrlTool` and the REST `POST /api/posts/{post}/media`
|
||||
* endpoint.
|
||||
*
|
||||
* URL syntax (`url:http,https`) and DNS resolvability (`active_url`) are
|
||||
* enforced at the request validation layer. SSRF defense beyond that is
|
||||
* the responsibility of network-level egress controls in production.
|
||||
*
|
||||
* 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)
|
||||
* 1. Stream the body to a temp file via Http::sink + a progress
|
||||
* callback that aborts mid-download once MAX_BYTES is exceeded.
|
||||
* 2. Validate the Content-Type against the MediaType enum's allow-list
|
||||
* 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
|
||||
* 3. Hand off to `Workspace::addMediaFromPath()` so storage path,
|
||||
* MIME re-detection, image normalization, and the Media row stay
|
||||
* in one place (same path as the web upload flow).
|
||||
* 4. 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
|
||||
{
|
||||
/**
|
||||
* Cap on URL-fetched payloads. Smaller than the web upload cap (which
|
||||
* can be 1 GB for direct uploads) because URL fetches have stricter
|
||||
* server-side concerns: bandwidth, timeout, and unbounded user input.
|
||||
* 50 MB covers a long photo or a short video; bigger files should be
|
||||
* uploaded directly.
|
||||
* Cap on URL-fetched payloads. Smaller than the web upload cap (1 GB)
|
||||
* because URL fetches have different operational constraints:
|
||||
* bandwidth, timeout, and unbounded user input. 50 MB covers a long
|
||||
* photo or a short video; bigger files should be uploaded directly.
|
||||
*/
|
||||
private const MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
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
|
||||
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
|
||||
|
|
@ -97,10 +79,6 @@ public function attachFromUrls(Post $post, array $urls): array
|
|||
*/
|
||||
private function processOne(Workspace $workspace, string $url, array $allowedTypes): ?array
|
||||
{
|
||||
if (! $this->isUrlSafe($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'media_');
|
||||
|
||||
try {
|
||||
|
|
@ -145,41 +123,6 @@ private function processOne(Workspace $workspace, string $url, array $allowedTyp
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 isUrlSafe(string $url): bool
|
||||
{
|
||||
if (self::$skipUrlSafety) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$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 filter_var(
|
||||
$host,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
) !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-then-merge so concurrent attach calls don't overwrite each
|
||||
* other's appended items in the JSON `media` column.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
it('attaches media from url', function () {
|
||||
Http::fake([
|
||||
'cdn.example.com/photo.png' => Http::response(
|
||||
'example.com/photo.png' => Http::response(
|
||||
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
||||
200,
|
||||
['Content-Type' => 'image/png'],
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->postJson(route('api.posts.attach-media', $this->post), [
|
||||
'urls' => ['https://cdn.example.com/photo.png'],
|
||||
'urls' => ['https://example.com/photo.png'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('attached_count', 1)
|
||||
|
|
@ -59,16 +59,16 @@
|
|||
|
||||
it('reports failures for unreachable urls', function () {
|
||||
Http::fake([
|
||||
'cdn.example.com/missing.png' => Http::response(null, 404),
|
||||
'example.com/missing.png' => Http::response(null, 404),
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->postJson(route('api.posts.attach-media', $this->post), [
|
||||
'urls' => ['https://cdn.example.com/missing.png'],
|
||||
'urls' => ['https://example.com/missing.png'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('attached_count', 0)
|
||||
->assertJsonPath('failed_urls.0', 'https://cdn.example.com/missing.png');
|
||||
->assertJsonPath('failed_urls.0', 'https://example.com/missing.png');
|
||||
});
|
||||
|
||||
it('cannot attach media to a post from another workspace', function () {
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->postJson(route('api.posts.attach-media', $post), [
|
||||
'urls' => ['https://cdn.example.com/photo.png'],
|
||||
'urls' => ['https://example.com/photo.png'],
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
test('attaches an image from url and creates a media row', function () {
|
||||
Http::fake([
|
||||
'cdn.example.com/photo.jpg' => Http::response(
|
||||
'example.com/photo.jpg' => Http::response(
|
||||
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
||||
200,
|
||||
['Content-Type' => 'image/png'],
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(AttachMediaFromUrlTool::class, [
|
||||
'post_id' => $this->post->id,
|
||||
'urls' => ['https://cdn.example.com/photo.jpg'],
|
||||
'urls' => ['https://example.com/photo.jpg'],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
|
@ -49,13 +49,13 @@
|
|||
|
||||
test('rejects url that returns non-image content type', function () {
|
||||
Http::fake([
|
||||
'evil.example.com/payload' => Http::response('not an image', 200, ['Content-Type' => 'text/html']),
|
||||
'example.org/payload' => Http::response('not an image', 200, ['Content-Type' => 'text/html']),
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(AttachMediaFromUrlTool::class, [
|
||||
'post_id' => $this->post->id,
|
||||
'urls' => ['https://evil.example.com/payload'],
|
||||
'urls' => ['https://example.org/payload'],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
|
@ -66,25 +66,25 @@
|
|||
|
||||
test('reports failures and successes separately', function () {
|
||||
Http::fake([
|
||||
'cdn.example.com/ok.png' => Http::response(
|
||||
'example.com/ok.png' => Http::response(
|
||||
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
|
||||
200,
|
||||
['Content-Type' => 'image/png'],
|
||||
),
|
||||
'cdn.example.com/missing.png' => Http::response(null, 404),
|
||||
'example.com/missing.png' => Http::response(null, 404),
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(AttachMediaFromUrlTool::class, [
|
||||
'post_id' => $this->post->id,
|
||||
'urls' => [
|
||||
'https://cdn.example.com/ok.png',
|
||||
'https://cdn.example.com/missing.png',
|
||||
'https://example.com/ok.png',
|
||||
'https://example.com/missing.png',
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee(['cdn.example.com/missing.png']);
|
||||
->assertSee(['example.com/missing.png']);
|
||||
|
||||
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
|
||||
expect($this->post->fresh()->media)->toHaveCount(1);
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(AttachMediaFromUrlTool::class, [
|
||||
'post_id' => $post->id,
|
||||
'urls' => ['https://cdn.example.com/photo.jpg'],
|
||||
'urls' => ['https://example.com/photo.jpg'],
|
||||
]);
|
||||
|
||||
$response->assertHasErrors(['Post not found.']);
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(AttachMediaFromUrlTool::class, [
|
||||
'post_id' => $this->post->id,
|
||||
'urls' => ['ftp://cdn.example.com/photo.jpg'],
|
||||
'urls' => ['ftp://example.com/photo.jpg'],
|
||||
]);
|
||||
|
||||
$response->assertHasErrors();
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
});
|
||||
|
||||
test('rejects more than 10 urls per call', function () {
|
||||
$urls = collect(range(1, 11))->map(fn ($i) => "https://cdn.example.com/photo-{$i}.jpg")->all();
|
||||
$urls = collect(range(1, 11))->map(fn ($i) => "https://example.com/photo-{$i}.jpg")->all();
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(AttachMediaFromUrlTool::class, [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
namespace Tests;
|
||||
|
||||
use App\Services\Post\MediaAttacher;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
|
|
@ -23,9 +22,5 @@ protected function setUp(): void
|
|||
parent::setUp();
|
||||
|
||||
$this->withoutVite();
|
||||
|
||||
// Bypass the SSRF check during tests so Http::fake() with
|
||||
// synthetic hosts like cdn.example.com isn't rejected.
|
||||
MediaAttacher::fakeUrlSafety();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue