Files
einundzwanzig-app/app/Jobs/FetchNostrProfileJob.php
T
Claude d46c0161fe security: medium-severity fixes (proxies, ssrf, uploads, lnurl, github_data)
- Trust the Forge reverse proxy and force https URLs in production so
  generated absolute URLs match the actual TLS termination.
- Reject Nostr profile photo URLs that aren't http(s) or that resolve to
  loopback / private (RFC1918) addresses to close an SSRF vector in
  FetchNostrProfileJob.
- Tighten image upload validation across meetup, course, and lecturer
  create/edit components: explicit mimes whitelist (jpeg, png, webp),
  max 5 MiB, and dimension cap of 4000x4000.
- Replace the silent "skip if exists" branch in LnurlAuthController with
  updateOrCreate so concurrent callers cannot race on the k1 record.
- Validate github_data on Meetup edit, decoding the JSON, and keep only
  the whitelisted keys (top, left, state) with strict type coercion to
  prevent storing arbitrary attacker-controlled JSON.
2026-05-03 12:57:57 +00:00

247 lines
8.1 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Ramsey\Uuid\Uuid;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
class FetchNostrProfileJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public ?User $user = null,
) {}
public function handle(): void
{
// Determine which users to process
if ($this->user) {
if (!$this->user->nostr) {
\Log::info('No nostr profile for user', ['user_id' => $this->user->id]);
return;
}
$users = collect([$this->user]);
} else {
$users = User::query()->whereNotNull('nostr')->get();
\Log::info('Fetching nostr profiles for multiple users', ['count' => $users->count()]);
}
// Filter valid npub authors
$authors = $users
->pluck('nostr')
->map(fn($nostr) => trim($nostr))
->filter(fn($nostr) => str_starts_with($nostr, 'npub1'))
->unique()
->values()
->toArray();
if (empty($authors)) {
\Log::warning('No valid nostr authors found');
return;
}
// Setup filter for kind 0 (profile metadata)
$subscription = new Subscription();
$filter = new Filter();
$filter->setAuthors($authors);
$filter->setKinds([0]);
$requestMessage = new RequestMessage($subscription->getId(), [$filter]);
// Setup relay set
$relays = [
new Relay('wss://nos.lol'),
];
$relaySet = new RelaySet();
$relaySet->setRelays($relays);
// Send request
$request = new Request($relaySet, $requestMessage);
try {
\Log::info('Fetching from relays', ['relay_count' => count($relays), 'author_count' => count($authors)]);
$response = $request->send();
$updated = 0;
$totalMessages = 0;
foreach ($response as $relayUrl => $relayResponses) {
$messageCount = count($relayResponses);
$totalMessages += $messageCount;
\Log::info('Received messages from relay', ['url' => $relayUrl, 'count' => $messageCount]);
foreach ($relayResponses as $message) {
if (!isset($message->event)) {
continue;
}
try {
$profile = json_decode($message->event->content, true, 512, JSON_THROW_ON_ERROR);
if (isset($profile['picture'])) {
$npub = (new Key)->convertPublicKeyToBech32($message->event->pubkey);
$user = User::query()->where('nostr', $npub)->first();
if (isset($profile['name'])) {
$user->name = $profile['name'];
$user->save();
}
if ($user) {
$this->downloadAndSaveProfilePhoto($user, $profile['picture']);
$updated++;
}
}
} catch (\JsonException $e) {
\Log::error('Failed to decode profile', [
'error' => $e->getMessage(),
'relay' => $relayUrl,
'pubkey' => $message->event->pubkey,
]);
} catch (\Exception $e) {
\Log::error('Failed to download profile photo', [
'error' => $e->getMessage(),
'relay' => $relayUrl,
'pubkey' => $message->event->pubkey,
]);
}
}
}
\Log::info('Finished updating nostr profiles', [
'total_messages' => $totalMessages,
'updated_count' => $updated,
]);
} catch (\Exception $e) {
\Log::error('Failed to fetch from relays', ['error' => $e->getMessage()]);
}
}
private function downloadAndSaveProfilePhoto(User $user, string $photoUrl): void
{
if (!$this->isPublicHttpUrl($photoUrl)) {
\Log::warning('Refused to download Nostr profile photo from disallowed URL', [
'user_id' => $user->id,
'url' => $photoUrl,
]);
return;
}
try {
// Download the image from the URL
$response = Http::timeout(10)->get($photoUrl);
if (!$response->successful()) {
\Log::warning('Failed to download profile photo', [
'user_id' => $user->id,
'url' => $photoUrl,
'status' => $response->status(),
]);
return;
}
// Store the file and update the user
tap($user->profile_photo_path, function ($previous) use ($user, $response, $photoUrl) {
$extension = $this->getImageExtension($response->header('Content-Type'), $photoUrl);
$path = 'profile-photos/'.Uuid::uuid1().$extension;
Storage::disk('public')
->put(
$path,
$response->body(),
);
$user->forceFill([
'profile_photo_path' => $path,
])->save();
if ($previous) {
Storage::disk('public')->delete($previous);
}
});
\Log::info('Profile photo updated from Nostr', [
'user_id' => $user->id,
'url' => $photoUrl,
]);
} catch (\Exception $e) {
\Log::error('Failed to save profile photo', [
'user_id' => $user->id,
'url' => $photoUrl,
'error' => $e->getMessage(),
]);
}
}
/**
* Reject URLs that are not http(s) or that resolve to a private/loopback
* address, to prevent SSRF when fetching arbitrary profile photo URLs.
*/
private function isPublicHttpUrl(string $url): bool
{
$parts = parse_url($url);
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
return false;
}
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = $parts['host'];
$ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
if (empty($ips)) {
return false;
}
foreach ($ips as $ip) {
if (!filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
)) {
return false;
}
}
return true;
}
private function getImageExtension(?string $contentType, string $url): string
{
// Try to get extension from content type
if ($contentType) {
$mimeMap = [
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
if (isset($mimeMap[$contentType])) {
return $mimeMap[$contentType];
}
}
// Fallback to URL extension
$pathInfo = pathinfo(parse_url($url, PHP_URL_PATH));
return $pathInfo['extension'] ?? 'jpg';
}
}