diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index 6bf2a388..36f04dd3 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -13,7 +13,6 @@ use App\Services\Automation\FeedParser; use App\Services\Brand\SafeHttpFetcher; use Carbon\CarbonImmutable; -use Illuminate\Support\Facades\Http; use RuntimeException; use Throwable; @@ -51,21 +50,19 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult return NodeRunResult::failed(__('automations.errors.fetch_rss_missing_url')); } + // SafeHttpFetcher::get() re-validates every redirect hop against the SSRF + // guard (not just the initial URL), so a public feed that 302s to an + // internal host is never followed. It throws on a blocked hop, connection + // failure, non-2xx status, or an excessive redirect chain — all of which + // are legitimate "this feed couldn't be fetched" failures for this node. try { - $this->safeHttp->guardAgainstSsrf($feedUrl); - } catch (RuntimeException) { - return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ - 'reason' => 'url_not_allowed', - 'url' => $feedUrl, + $response = $this->safeHttp->get($feedUrl); + } catch (RuntimeException $e) { + return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), [ + 'message' => $e->getMessage(), ]); } - $response = Http::timeout(10)->get($feedUrl); - - if (! $response->successful()) { - return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]); - } - $items = $this->parser->parse($response->body()); if ($items === null) { diff --git a/app/Actions/Automation/Node/RunHttpRequestNode.php b/app/Actions/Automation/Node/RunHttpRequestNode.php index 0036cdc7..15063a78 100644 --- a/app/Actions/Automation/Node/RunHttpRequestNode.php +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -364,7 +364,9 @@ private function buildRequest(array $config, array $context): PendingRequest $request = $request->withHeaders($headers); } - return $request->withUserAgent(config('trypost.user_agent')); + return $request + ->withUserAgent(config('trypost.user_agent')) + ->withOptions($this->safeHttp->redirectGuardOptions()); } /** diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php index 0b5498c9..5bf25090 100644 --- a/app/Actions/Automation/Node/RunWebhookNode.php +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -75,6 +75,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult try { $response = Http::withHeaders($headers) ->withUserAgent(config('trypost.user_agent')) + ->withOptions(['allow_redirects' => false]) ->send($method, $url, ['json' => $payload]); } catch (Throwable $e) { return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [ diff --git a/app/Helpers/Upload.php b/app/Helpers/Upload.php index 6a1c77f9..0dc2c58d 100644 --- a/app/Helpers/Upload.php +++ b/app/Helpers/Upload.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Illuminate\Support\Facades\Http; +use App\Services\Brand\SafeHttpFetcher; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -22,7 +22,7 @@ function uploadFromUrl(?string $url, string $directory = 'social-accounts'): ?st } try { - $response = Http::timeout(10)->get($url); + $response = app(SafeHttpFetcher::class)->guardedRequest($url)->timeout(10)->get($url); if (! $response->successful()) { Log::warning('uploadFromUrl: Failed to download', [ diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index eab0588d..3997bf40 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -9,16 +9,17 @@ use App\Http\Requests\App\Asset\StoreChunkedAssetRequest; use App\Http\Resources\App\MediaResource; use App\Models\Media; +use App\Services\Brand\SafeHttpFetcher; use App\Services\UnsplashService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class AssetController extends Controller @@ -115,7 +116,7 @@ public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse ]); } - public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash): MediaResource + public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource { $workspace = $request->user()->currentWorkspace; @@ -128,7 +129,13 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash->trackDownload($downloadLocation); } - $response = Http::timeout(30)->get(data_get($validated, 'url')); + $url = (string) data_get($validated, 'url'); + + try { + $response = $safeHttp->guardedRequest($url)->timeout(30)->get($url); + } catch (RuntimeException) { + abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL'); + } if ($response->failed()) { abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Failed to download image from URL'); diff --git a/app/Http/Controllers/App/LinkPreviewController.php b/app/Http/Controllers/App/LinkPreviewController.php new file mode 100644 index 00000000..8e2209e1 --- /dev/null +++ b/app/Http/Controllers/App/LinkPreviewController.php @@ -0,0 +1,25 @@ +fetch($request->validated('url')); + + if ($card === null) { + return response()->noContent(); + } + + return response()->json($card->toArray()); + } +} diff --git a/app/Http/Requests/App/Post/LinkPreviewRequest.php b/app/Http/Requests/App/Post/LinkPreviewRequest.php new file mode 100644 index 00000000..c932f33f --- /dev/null +++ b/app/Http/Requests/App/Post/LinkPreviewRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'url' => ['required', 'string', 'max:2048'], + ]; + } +} diff --git a/app/Services/Brand/SafeHttpFetcher.php b/app/Services/Brand/SafeHttpFetcher.php index 667146f6..9c842887 100644 --- a/app/Services/Brand/SafeHttpFetcher.php +++ b/app/Services/Brand/SafeHttpFetcher.php @@ -5,9 +5,11 @@ namespace App\Services\Brand; use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use RuntimeException; +use Symfony\Component\DomCrawler\UriResolver; /** * HTTP fetcher with SSRF protection, timeout, redirect cap and a branded user-agent. @@ -36,13 +38,38 @@ public function get(string $url): Response { $this->guardAgainstSsrf($url); - try { - $response = Http::timeout(self::TIMEOUT_SECONDS) - ->withUserAgent(self::USER_AGENT) - ->withOptions(['allow_redirects' => ['max' => self::MAX_REDIRECTS]]) - ->get($url); - } catch (ConnectionException $e) { - throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()])); + $currentUrl = $url; + + // Redirects are followed manually (allow_redirects disabled) so that every + // hop's Location target is re-validated against the SSRF guard before it is + // ever requested. A public page could otherwise 302 to an internal host and + // Guzzle's built-in redirect following would fetch it without re-checking. + for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) { + try { + $response = Http::timeout(self::TIMEOUT_SECONDS) + ->withUserAgent(self::USER_AGENT) + ->withOptions(['allow_redirects' => false]) + ->get($currentUrl); + } catch (ConnectionException $e) { + throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => $e->getMessage()])); + } + + if (! $response->redirect() || $hop >= self::MAX_REDIRECTS) { + break; + } + + $location = $response->header('Location'); + + if ($location === '') { + break; + } + + $currentUrl = (string) UriResolver::resolve($location, $currentUrl); + $this->guardAgainstSsrf($currentUrl); + } + + if ($response->redirect()) { + throw new RuntimeException(__('workspaces.create.autofill_errors.unreachable', ['reason' => 'too many redirects'])); } if ($response->failed()) { @@ -65,6 +92,43 @@ public function tryGet(string $url): ?Response } } + /** + * Guzzle allow_redirects options that re-run the SSRF guard on every hop. + * For callers that follow redirects on user-supplied URLs with methods/bodies + * that SafeHttpFetcher::get() cannot express. + * + * @return array + */ + public function redirectGuardOptions(int $max = self::MAX_REDIRECTS): array + { + return [ + 'allow_redirects' => [ + 'max' => $max, + 'strict' => true, + 'protocols' => ['http', 'https'], + 'on_redirect' => function ($request, $response, $uri): void { + $this->guardAgainstSsrf((string) $uri); + }, + ], + ]; + } + + /** + * A PendingRequest with the SSRF guard applied to $url and redirect handling + * pre-configured (per-hop re-guard when following, or no redirects). Callers + * add their own timeout / sink / headers and dispatch to the SAME $url, so a + * user-supplied URL can never be fetched without the guard and redirect + * protection. + */ + public function guardedRequest(string $url, bool $followRedirects = true): PendingRequest + { + $this->guardAgainstSsrf($url); + + return Http::withUserAgent(self::USER_AGENT)->withOptions( + $followRedirects ? $this->redirectGuardOptions() : ['allow_redirects' => false], + ); + } + public function guardAgainstSsrf(string $url): void { $parts = parse_url($url); @@ -80,6 +144,13 @@ public function guardAgainstSsrf(string $url): void throw new RuntimeException(__('workspaces.create.autofill_errors.missing_host')); } + // Self-hosted operators can opt into fetching their own internal + // network. Only the private/reserved-IP rejection below is skipped; + // the scheme and host checks above still always apply. + if ((bool) config('trypost.security.allow_private_network')) { + return; + } + $ip = gethostbyname($host); if ($ip === $host && filter_var($host, FILTER_VALIDATE_IP) === false) { diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index 37a71267..ed178282 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -8,7 +8,7 @@ use App\Models\Media; use App\Models\Post; use App\Models\Workspace; -use Illuminate\Support\Facades\Http; +use App\Services\Brand\SafeHttpFetcher; use RuntimeException; use Throwable; @@ -24,6 +24,8 @@ */ class MediaAttacher { + public function __construct(private readonly SafeHttpFetcher $safeHttp) {} + /** * @param array $urls * @return array{attached: array>, failed: array} @@ -159,10 +161,10 @@ private function download(string $url): ?array $temp = tempnam(sys_get_temp_dir(), 'media_'); try { - $response = Http::timeout(20) + $response = $this->safeHttp->guardedRequest($url, followRedirects: false) + ->timeout(20) ->sink($temp) ->withOptions([ - 'allow_redirects' => false, 'progress' => static function ($total, $downloaded) use ($cap): void { if ($downloaded > $cap) { throw new RuntimeException('exceeded max bytes'); diff --git a/app/Services/Social/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index 0fbc70be..ae56ad84 100644 --- a/app/Services/Social/BlueskyLexicon.php +++ b/app/Services/Social/BlueskyLexicon.php @@ -37,6 +37,8 @@ final class BlueskyLexicon public const EMBED_VIDEO = 'app.bsky.embed.video'; + public const EMBED_EXTERNAL = 'app.bsky.embed.external'; + public const FACET_LINK = 'app.bsky.richtext.facet#link'; public const FACET_MENTION = 'app.bsky.richtext.facet#mention'; diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index f45f541c..7c6a128a 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -9,13 +9,18 @@ use App\Exceptions\Social\BlueskyPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Brand\SafeHttpFetcher; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Services\Social\LinkCard\LinkCardFetcher; +use App\Services\Social\LinkCard\LinkCardMetadata; +use App\Support\UrlDetector; use Carbon\CarbonInterface; use Exception; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; class BlueskyPublisher @@ -25,6 +30,9 @@ class BlueskyPublisher /** Seconds allowed for a remote media download (large videos need time). */ private const DOWNLOAD_TIMEOUT = 600; + /** Short timeout for the card thumbnail download (attacker-influenceable og:image, not a large media upload). */ + private const THUMB_DOWNLOAD_TIMEOUT = 15; + /** Re-upload a transiently-failing transcode this many times before giving up. */ private const VIDEO_UPLOAD_ATTEMPTS = 3; @@ -95,6 +103,13 @@ public function publish(PostPlatform $postPlatform): array } } + // No image or video embed, so a bare link can carry a preview card. + // Bluesky does not hydrate cards server-side: the client must attach an + // app.bsky.embed.external built from the page's OpenGraph metadata. + if ($embed === null && $medias->isEmpty() && $content !== null) { + $embed = $this->buildExternalEmbed($postPlatform->socialAccount, $service, $content); + } + // Parse facets (links, mentions, hashtags) from text $text = $content ?? ''; $facets = $this->parseFacets($text); @@ -142,9 +157,63 @@ public function publish(PostPlatform $postPlatform): array ]; } - private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array + /** + * Build an app.bsky.embed.external card for the first link in the text, or + * null when there is no link, the scrape fails, or the page has no metadata. + * The og:image is re-uploaded as the card thumb because Bluesky's `thumb` + * only accepts a blob, never an external URL. Any failure degrades to null + * so the post still publishes with just the link facet. + */ + private function buildExternalEmbed(SocialAccount $account, string $service, string $content): ?array { - $tempFile = $this->downloadToTempFile($url, 'bsky_blob_'); + $card = app(LinkCardFetcher::class)->fetch($content); + + if ($card === null) { + return null; + } + + $external = [ + 'uri' => $card->uri, + 'title' => $card->title, + 'description' => $card->description, + ]; + + $thumb = $this->uploadCardThumb($account, $service, $card); + + if ($thumb !== null) { + $external['thumb'] = $thumb; + } + + return [ + '$type' => BlueskyLexicon::EMBED_EXTERNAL, + 'external' => $external, + ]; + } + + /** + * Download the card's og:image and upload it as a blob for the thumb. + * Returns null (card renders without a thumbnail) when there is no image or + * the upload fails. A JPEG hint routes it through the image optimizer, which + * re-encodes any static image and enforces Bluesky's 1MB blob limit. + */ + private function uploadCardThumb(SocialAccount $account, string $service, LinkCardMetadata $card): ?array + { + if ($card->imageUrl === null) { + return null; + } + + try { + app(SafeHttpFetcher::class)->guardAgainstSsrf($card->imageUrl); + } catch (RuntimeException) { + return null; + } + + return $this->uploadBlob($account, $service, $card->imageUrl, 'image/jpeg', self::THUMB_DOWNLOAD_TIMEOUT, followRedirects: false); + } + + private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType, int $downloadTimeout = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?array + { + $tempFile = $this->downloadToTempFile($url, 'bsky_blob_', $downloadTimeout, $followRedirects); if ($tempFile === null) { return null; @@ -202,8 +271,15 @@ private function uploadBlob(SocialAccount $account, string $service, string $url * Download a remote media file to a temp file. Returns the temp path, or * null (after cleaning up) if the temp file can't be created, the download * fails, or the downloaded file is empty. + * + * $followRedirects defaults to true for the media/video paths, which + * download from our own storage/CDN URLs. The card thumb path passes + * false because the source is an attacker-influenceable og:image that + * was only guarded against SSRF on its original URL — a redirect on that + * hop must not be followed without re-guarding, so it is simply not + * followed at all (the thumb degrades to null instead). */ - private function downloadToTempFile(string $url, string $prefix): ?string + private function downloadToTempFile(string $url, string $prefix, int $timeoutSeconds = self::DOWNLOAD_TIMEOUT, bool $followRedirects = true): ?string { $tempFile = tempnam(sys_get_temp_dir(), $prefix); @@ -214,7 +290,13 @@ private function downloadToTempFile(string $url, string $prefix): ?string } try { - $response = Http::withOptions(['sink' => $tempFile])->timeout(self::DOWNLOAD_TIMEOUT)->get($url); + $options = ['sink' => $tempFile]; + + if (! $followRedirects) { + $options['allow_redirects'] = false; + } + + $response = Http::withOptions($options)->timeout($timeoutSeconds)->get($url); if ($response->failed()) { throw new Exception('HTTP '.$response->status()); @@ -555,14 +637,14 @@ private function parseFacets(string $text): array // Parse URLs preg_match_all( - '/(https?:\/\/[^\s]+)/u', + UrlDetector::URL_PATTERN, $text, $urlMatches, PREG_OFFSET_CAPTURE ); foreach ($urlMatches[0] as $match) { - $url = $this->trimTrailingUrlPunctuation($match[0]); + $url = UrlDetector::trimTrailingPunctuation($match[0]); $start = (int) $match[1]; $end = $start + strlen($url); @@ -676,24 +758,6 @@ private function resolveHandleToDid(string $handle): ?string } } - /** - * Trailing sentence punctuation and an unmatched closing paren are almost - * never part of a URL (e.g. "see https://x.com)."). Mirrors the official - * atproto link tokenizer so the link facet doesn't over-extend past the URL. - */ - private function trimTrailingUrlPunctuation(string $url): string - { - if (preg_match('/[.,;:!?]$/', $url)) { - $url = substr($url, 0, -1); - } - - if (str_ends_with($url, ')') && ! str_contains($url, '(')) { - $url = substr($url, 0, -1); - } - - return $url; - } - private function buildPostUrl(string $handle, string $postId): string { $webApp = (string) config('trypost.platforms.bluesky.web_app'); diff --git a/app/Services/Social/LinkCard/LinkCardFetcher.php b/app/Services/Social/LinkCard/LinkCardFetcher.php new file mode 100644 index 00000000..d987cf42 --- /dev/null +++ b/app/Services/Social/LinkCard/LinkCardFetcher.php @@ -0,0 +1,70 @@ +addMinutes(self::CACHE_MINUTES), + fn (): ?array => $this->build($url)?->toArray(), + ); + + return $data === null ? null : LinkCardMetadata::fromArray($data); + } + + private function build(string $url): ?LinkCardMetadata + { + $response = $this->http->tryGet($url); + + if ($response === null) { + return null; + } + + $meta = $this->extractor->extract($response->body(), $url); + $title = data_get($meta, 'title'); + $description = data_get($meta, 'description'); + + if ($title === null && $description === null) { + return null; + } + + return new LinkCardMetadata( + uri: $url, + title: $title ?? '', + description: $description ?? '', + imageUrl: data_get($meta, 'image'), + ); + } +} diff --git a/app/Services/Social/LinkCard/LinkCardMetadata.php b/app/Services/Social/LinkCard/LinkCardMetadata.php new file mode 100644 index 00000000..95d50346 --- /dev/null +++ b/app/Services/Social/LinkCard/LinkCardMetadata.php @@ -0,0 +1,61 @@ + $this->uri, + 'domain' => $this->domain(), + 'title' => $this->title, + 'description' => $this->description, + 'image' => $this->imageUrl, + ]; + } + + /** + * The bare display host for the card (e.g. "nyt.com" from + * "https://www.nyt.com/x"), so the frontend renders it without parsing URLs. + */ + private function domain(): string + { + $host = Uri::of($this->uri)->host(); + + return $host === null || $host === '' + ? $this->uri + : Str::chopStart($host, 'www.'); + } +} diff --git a/app/Services/Social/LinkCard/OpenGraphExtractor.php b/app/Services/Social/LinkCard/OpenGraphExtractor.php new file mode 100644 index 00000000..dcf62f38 --- /dev/null +++ b/app/Services/Social/LinkCard/OpenGraphExtractor.php @@ -0,0 +1,75 @@ + and meta-description fallbacks. + * Deliberately separate from HomepageMetaExtractor, which is brand-tuned and + * intentionally excludes og:image. + */ +final class OpenGraphExtractor +{ + /** + * @return array{title: ?string, description: ?string, image: ?string} + */ + public function extract(string $html, string $baseUrl): array + { + $crawler = new Crawler($html, $baseUrl); + + return [ + 'title' => $this->title($crawler), + 'description' => $this->metaContent($crawler, 'property', 'og:description') + ?? $this->metaContent($crawler, 'name', 'description'), + 'image' => $this->image($crawler, $baseUrl), + ]; + } + + private function title(Crawler $crawler): ?string + { + $ogTitle = $this->metaContent($crawler, 'property', 'og:title'); + + if ($ogTitle !== null) { + return $ogTitle; + } + + $title = $crawler->filter('title')->first(); + + if ($title->count() === 0) { + return null; + } + + $text = trim($title->text('')); + + return $text === '' ? null : $text; + } + + private function image(Crawler $crawler, string $baseUrl): ?string + { + $image = $this->metaContent($crawler, 'property', 'og:image'); + + if ($image === null) { + return null; + } + + return UriResolver::resolve($image, $baseUrl); + } + + private function metaContent(Crawler $crawler, string $attr, string $value): ?string + { + $node = $crawler->filter(sprintf('meta[%s="%s"]', $attr, $value))->first(); + + if ($node->count() === 0) { + return null; + } + + $content = trim((string) $node->attr('content', '')); + + return $content === '' ? null : $content; + } +} diff --git a/app/Support/UrlDetector.php b/app/Support/UrlDetector.php new file mode 100644 index 00000000..1ac84586 --- /dev/null +++ b/app/Support/UrlDetector.php @@ -0,0 +1,38 @@ + env('SELF_HOSTED', true), + /* + |-------------------------------------------------------------------------- + | Security + |-------------------------------------------------------------------------- + | + | SafeHttpFetcher blocks requests to private/reserved IP ranges (SSRF + | protection) by default. Self-hosted operators who need to fetch from + | their own internal network (e.g. an internal RSS feed or webhook) can + | opt in here. Leave disabled unless you understand the SSRF risk. + | + */ + + 'security' => [ + 'allow_private_network' => (bool) env('TRYPOST_ALLOW_PRIVATE_NETWORK', false), + ], + /* |-------------------------------------------------------------------------- | Billing diff --git a/resources/js/components/posts/previews/BlueskyPreview.vue b/resources/js/components/posts/previews/BlueskyPreview.vue index f9c2a5b8..b5dd3942 100644 --- a/resources/js/components/posts/previews/BlueskyPreview.vue +++ b/resources/js/components/posts/previews/BlueskyPreview.vue @@ -1,5 +1,9 @@