Skip to content

Commit d2ce066

Browse files
committed
Update InboxController, only use Person actors for inbox activities
1 parent 656df7c commit d2ce066

File tree

2 files changed

+356
-2
lines changed

2 files changed

+356
-2
lines changed

app/Http/Controllers/InboxController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace App\Http\Controllers;
44

55
use App\Http\Middleware\FederationEnabled;
6-
use App\Http\Middleware\VerifyHttpSignature;
6+
use App\Http\Middleware\VerifyUserHttpSignature;
77
use App\Jobs\Federation\ProcessInboxActivity;
88
use App\Models\Profile;
99
use Illuminate\Http\Request;
@@ -14,7 +14,7 @@ class InboxController extends Controller
1414
public function __construct()
1515
{
1616
$this->middleware(FederationEnabled::class);
17-
$this->middleware(VerifyHttpSignature::class);
17+
$this->middleware(VerifyUserHttpSignature::class);
1818
}
1919

2020
public function userInbox(Request $request, Profile $actor)
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use App\Jobs\Federation\DiscoverInstance;
6+
use App\Models\Profile;
7+
use App\Services\ActivityPubService;
8+
use App\Services\HttpSignatureService;
9+
use App\Services\SanitizeService;
10+
use Carbon\Carbon;
11+
use Closure;
12+
use Exception;
13+
use Illuminate\Http\Request;
14+
use Illuminate\Support\Facades\Log;
15+
16+
class VerifyUserHttpSignature
17+
{
18+
protected $signatureService;
19+
20+
public function __construct(HttpSignatureService $signatureService)
21+
{
22+
$this->signatureService = $signatureService;
23+
}
24+
25+
public function handle(Request $request, Closure $next)
26+
{
27+
$signature = $request->header('Signature');
28+
29+
if (! $signature) {
30+
return $this->unauthorized('Missing signature header');
31+
}
32+
33+
try {
34+
$parsed = $this->parseSignatureHeader($signature);
35+
} catch (Exception $e) {
36+
throw $e;
37+
}
38+
39+
$keyId = $parsed['keyId'] ?? null;
40+
41+
if (! $keyId) {
42+
return $this->unauthorized('Missing keyId in signature');
43+
}
44+
45+
$actorUrl = preg_replace('/#.*$/', '', $keyId);
46+
47+
$originDomain = parse_url($actorUrl, PHP_URL_HOST);
48+
if ($originDomain) {
49+
$request->attributes->set('activitypub_origin_domain', $originDomain);
50+
}
51+
52+
$signedHeadersList = isset($parsed['headers'])
53+
? explode(' ', strtolower($parsed['headers']))
54+
: [];
55+
56+
$requiredHeaders = ['(request-target)', 'host', 'date'];
57+
foreach ($requiredHeaders as $required) {
58+
if (! in_array($required, $signedHeadersList)) {
59+
return $this->unauthorized('Missing required signed header: '.$required);
60+
}
61+
}
62+
63+
$headers = $this->buildSignedHeaders($request, $signedHeadersList);
64+
65+
if (! $this->isDateValid($headers['Date'] ?? '')) {
66+
return $this->unauthorized('Invalid or expired Date header');
67+
}
68+
69+
if (! app(SanitizeService::class)->url($actorUrl, true)) {
70+
return $this->unauthorized('Invalid actor');
71+
}
72+
73+
if (in_array('digest', $signedHeadersList)) {
74+
$digest = $request->header('Digest');
75+
if (! $digest) {
76+
return $this->unauthorized('Digest header was signed but not present');
77+
}
78+
79+
if (! $this->signatureService->verifyDigest($digest, $request->getContent())) {
80+
return $this->unauthorized('Invalid Digest header');
81+
}
82+
}
83+
84+
$profile = Profile::where('uri', $actorUrl)->first();
85+
if ($profile && $profile->public_key) {
86+
if ($this->verifyWithKey($signature, $profile->public_key, $headers, $request)) {
87+
$request->attributes->set('activitypub_actor', $profile);
88+
89+
return $next($request);
90+
}
91+
if (config('logging.dev_log')) {
92+
Log::debug('ActivityPub signature verification failed with cached Profile key, fetching fresh', [
93+
'actor' => $actorUrl,
94+
]);
95+
}
96+
}
97+
98+
$actorData = $this->fetchActorData($actorUrl);
99+
100+
if (! $actorData || ! isset($actorData['publicKey']['publicKeyPem'])) {
101+
$isDeleteActivity = $this->isDeleteActivity($request);
102+
103+
if ($isDeleteActivity) {
104+
if (config('logging.dev_log')) {
105+
Log::info('Accepting Delete activity despite missing actor data', [
106+
'actor' => $actorUrl,
107+
'reason' => 'Actor likely deleted',
108+
]);
109+
}
110+
111+
return response()->json(['message' => 'Accepted'], 201);
112+
}
113+
114+
return $this->unauthorized('Unable to fetch public key');
115+
}
116+
117+
if (($actorData['type'] ?? null) !== 'Person') {
118+
if (config('logging.dev_log')) {
119+
Log::warning('Rejected non-Person actor for user inbox', [
120+
'actor' => $actorUrl,
121+
'type' => $actorData['type'] ?? 'unknown',
122+
]);
123+
}
124+
125+
return $this->unauthorized('User inboxes only accept Person actors');
126+
}
127+
128+
$publicKey = $actorData['publicKey']['publicKeyPem'];
129+
130+
if (! $this->verifyWithKey($signature, $publicKey, $headers, $request)) {
131+
return $this->unauthorized('Invalid signature');
132+
}
133+
134+
$actor = $this->updateOrCreateActorProfile($actorUrl, $actorData);
135+
136+
if (! $actor) {
137+
return response()->json(['message' => 'Accepted'], 201);
138+
}
139+
140+
$request->attributes->set('activitypub_actor', $actor);
141+
142+
return $next($request);
143+
}
144+
145+
/**
146+
* Build the headers array based on what was actually signed
147+
*/
148+
protected function buildSignedHeaders(Request $request, array $signedHeadersList): array
149+
{
150+
$headers = [];
151+
152+
foreach ($signedHeadersList as $headerName) {
153+
switch ($headerName) {
154+
case '(request-target)':
155+
$headers['(request-target)'] = strtolower($request->method()).' '.$request->getRequestUri();
156+
break;
157+
158+
case 'host':
159+
$headers['Host'] = $request->header('Host');
160+
break;
161+
162+
case 'date':
163+
$headers['Date'] = $request->header('Date');
164+
break;
165+
166+
case 'content-type':
167+
if ($request->hasHeader('Content-Type')) {
168+
$headers['Content-Type'] = $request->header('Content-Type');
169+
}
170+
break;
171+
172+
case 'digest':
173+
if ($request->hasHeader('Digest')) {
174+
$headers['Digest'] = $request->header('Digest');
175+
}
176+
break;
177+
178+
case 'content-length':
179+
if ($request->hasHeader('Content-Length')) {
180+
$headers['Content-Length'] = $request->header('Content-Length');
181+
}
182+
break;
183+
184+
case 'user-agent':
185+
if ($request->hasHeader('User-Agent')) {
186+
$headers['User-Agent'] = $request->header('User-Agent');
187+
}
188+
break;
189+
190+
case 'accept':
191+
if ($request->hasHeader('Accept')) {
192+
$headers['Accept'] = $request->header('Accept');
193+
}
194+
break;
195+
196+
default:
197+
$headerValue = $request->header($headerName);
198+
if ($headerValue) {
199+
$properCase = implode('-', array_map('ucfirst', explode('-', $headerName)));
200+
$headers[$properCase] = $headerValue;
201+
}
202+
break;
203+
}
204+
}
205+
206+
return $headers;
207+
}
208+
209+
protected function verifyWithKey(string $signature, string $publicKey, array $headers, Request $request): bool
210+
{
211+
return $this->signatureService->verify(
212+
$signature,
213+
$publicKey,
214+
$headers,
215+
$request->method(),
216+
$request->getRequestUri()
217+
);
218+
}
219+
220+
protected function parseSignatureHeader(string $signature): array
221+
{
222+
$parts = [];
223+
224+
if (! preg_match_all('/(\w+)="([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"/', $signature, $matches, PREG_SET_ORDER)) {
225+
throw new Exception('Malformed signature header');
226+
}
227+
228+
foreach ($matches as $match) {
229+
$parts[$match[1]] = str_replace('\\"', '"', $match[2]);
230+
}
231+
232+
if (! isset($parts['keyId']) || ! isset($parts['signature'])) {
233+
throw new Exception('Missing required signature components');
234+
}
235+
236+
return $parts;
237+
}
238+
239+
protected function isDateValid(string $dateHeader): bool
240+
{
241+
if ($dateHeader === '') {
242+
return false;
243+
}
244+
245+
try {
246+
$date = Carbon::parse($dateHeader, 'UTC');
247+
} catch (\Throwable $e) {
248+
return false;
249+
}
250+
251+
// @phpstan-ignore-next-line
252+
return $date->diffInRealSeconds(now()) <= 3600;
253+
}
254+
255+
protected function fetchActorData(string $actorUrl)
256+
{
257+
$lockKey = 'api:f:fetch_actor:'.sha1(strtolower($actorUrl));
258+
259+
return cache()->lock($lockKey, 30)->get(function () use ($actorUrl) {
260+
try {
261+
$response = app(ActivityPubService::class)->get($actorUrl);
262+
263+
if ($response) {
264+
return $response;
265+
} else {
266+
if (config('logging.dev_log')) {
267+
Log::warning('Failed to fetch ActivityPub actor', [
268+
'url' => $actorUrl,
269+
]);
270+
}
271+
272+
return false;
273+
}
274+
275+
} catch (Exception $e) {
276+
if (config('logging.dev_log')) {
277+
Log::error('Exception fetching ActivityPub actor', [
278+
'url' => $actorUrl,
279+
'error' => $e->getMessage(),
280+
]);
281+
}
282+
283+
return false;
284+
}
285+
286+
});
287+
}
288+
289+
protected function updateOrCreateActor(string $actorUrl, array $actorData): ?Profile
290+
{
291+
return $this->updateOrCreateActorProfile($actorUrl, $actorData);
292+
}
293+
294+
protected function updateOrCreateActorProfile(string $actorUrl, array $actorData)
295+
{
296+
$domain = parse_url($actorUrl, PHP_URL_HOST);
297+
$appDomain = parse_url(config('app.url'), PHP_URL_HOST);
298+
if (strtolower($domain) === strtolower($appDomain)) {
299+
return false;
300+
}
301+
$username = app(SanitizeService::class)->cleanPlainText($actorData['preferredUsername']);
302+
$acct = $username.'@'.$domain;
303+
304+
$res = Profile::updateOrCreate(
305+
[
306+
'uri' => $actorUrl,
307+
],
308+
[
309+
'username' => $acct,
310+
'name' => app(SanitizeService::class)->cleanPlainText($actorData['name'] ?? $username),
311+
'bio' => app(SanitizeService::class)->cleanHtmlWithSpacing($actorData['summary'] ?? null),
312+
'inbox_url' => $actorData['inbox'] ?? null,
313+
'avatar' => data_get($actorData, 'icon.url'),
314+
'outbox_url' => $actorData['outbox'] ?? null,
315+
'followers_url' => $actorData['followers'] ?? null,
316+
'following_url' => $actorData['following'] ?? null,
317+
'shared_inbox_url' => data_get($actorData, 'endpoints.sharedInbox', null) ?? null,
318+
'public_key' => data_get($actorData, 'publicKey.publicKeyPem', null) ?? null,
319+
'manuallyApprovesFollowers' => data_get($actorData, 'manuallyApprovesFollowers', false),
320+
'last_fetched_at' => now(),
321+
'local' => false,
322+
'domain' => $domain,
323+
]
324+
);
325+
326+
DiscoverInstance::dispatch($actorUrl)->onQueue('activitypub-in');
327+
328+
return $res;
329+
330+
}
331+
332+
protected function unauthorized(string $message): \Illuminate\Http\JsonResponse
333+
{
334+
return response()->json(['error' => $message], 401);
335+
}
336+
337+
/**
338+
* Check if the request contains a Delete activity
339+
*/
340+
protected function isDeleteActivity(Request $request): bool
341+
{
342+
try {
343+
$body = json_decode($request->getContent(), true);
344+
345+
if (! $body || ! isset($body['type'])) {
346+
return false;
347+
}
348+
349+
return $body['type'] === 'Delete';
350+
} catch (\Throwable $e) {
351+
return false;
352+
}
353+
}
354+
}

0 commit comments

Comments
 (0)