Skip to content

Commit 0eed4fc

Browse files
author
numen-bot
committed
fix(security): IDOR, SSRF, space scoping, rate limiting for competitor differentiation
- IDOR: Add space ownership checks to CompetitorController (crawl, alerts, destroyAlert), CompetitorSourceController (index, store, show, update, destroy), DifferentiationController (index, show, summary), and GraphQL mutations (TriggerCompetitorCrawl, DeleteCompetitorSource, DeleteCompetitorAlert, UpdateCompetitorSource) - SSRF: Add ExternalUrl rule to url/feed_url in StoreCompetitorSourceRequest and UpdateCompetitorSourceRequest; add ExternalUrl rule to slack_webhook/webhook_url in StoreCompetitorAlertRequest - Space scoping: Verify space_id access on all collection endpoints - Rate limiting: Add throttle:5,1 middleware to crawl trigger route - Quota: Enforce max 50 competitor sources per space in store() - Pre-existing: Remove duplicate match arm and duplicate extractFromBrief() method in ContentFingerprintService (caused phpstan errors)
1 parent 4401bbb commit 0eed4fc

12 files changed

+78
-22
lines changed

app/GraphQL/Mutations/DeleteCompetitorAlert.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ class DeleteCompetitorAlert
1010
public function __invoke(mixed $root, array $args): ?CompetitorAlert
1111
{
1212
$alert = CompetitorAlert::find($args['id']);
13-
$alert?->delete();
13+
14+
if ($alert) {
15+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
16+
abort_if($currentSpace && $alert->space_id !== $currentSpace->id, 403);
17+
18+
$alert->delete();
19+
}
1420

1521
return $alert;
1622
}

app/GraphQL/Mutations/DeleteCompetitorSource.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ class DeleteCompetitorSource
1010
public function __invoke(mixed $root, array $args): ?CompetitorSource
1111
{
1212
$source = CompetitorSource::find($args['id']);
13-
$source?->delete();
13+
14+
if ($source) {
15+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
16+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
17+
18+
$source->delete();
19+
}
1420

1521
return $source;
1622
}

app/GraphQL/Mutations/TriggerCompetitorCrawl.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ class TriggerCompetitorCrawl
1111
public function __invoke(mixed $root, array $args): bool
1212
{
1313
$source = CompetitorSource::findOrFail($args['source_id']);
14+
15+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
16+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
17+
1418
CrawlCompetitorSourceJob::dispatch($source);
1519

1620
return true;

app/GraphQL/Mutations/UpdateCompetitorSource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ class UpdateCompetitorSource
1010
public function __invoke(mixed $root, array $args): CompetitorSource
1111
{
1212
$source = CompetitorSource::findOrFail($args['id']);
13+
14+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
15+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
16+
1317
$source->update($args['input']);
1418

1519
return $source->fresh() ?? $source;

app/Http/Controllers/Api/CompetitorController.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public function content(Request $request): AnonymousResourceCollection
2828
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
2929
]);
3030

31+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
32+
abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403);
33+
3134
$query = CompetitorContentItem::query()
3235
->whereHas('source', fn ($q) => $q->where('space_id', $validated['space_id']))
3336
->with('source')
@@ -50,6 +53,9 @@ public function crawl(string $id): JsonResponse
5053
{
5154
$source = CompetitorSource::findOrFail($id);
5255

56+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
57+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
58+
5359
CrawlCompetitorSourceJob::dispatch($source);
5460

5561
return response()->json(['message' => 'Crawl job dispatched', 'source_id' => $source->id]);
@@ -65,6 +71,9 @@ public function alerts(Request $request): AnonymousResourceCollection
6571
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
6672
]);
6773

74+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
75+
abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403);
76+
6877
$alerts = CompetitorAlert::where('space_id', $validated['space_id'])
6978
->orderByDesc('created_at')
7079
->paginate((int) ($validated['per_page'] ?? 20));
@@ -88,6 +97,10 @@ public function storeAlert(StoreCompetitorAlertRequest $request): JsonResponse
8897
public function destroyAlert(string $id): JsonResponse
8998
{
9099
$alert = CompetitorAlert::findOrFail($id);
100+
101+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
102+
abort_if($currentSpace && $alert->space_id !== $currentSpace->id, 403);
103+
91104
$alert->delete();
92105

93106
return response()->json(null, 204);

app/Http/Controllers/Api/CompetitorSourceController.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public function index(Request $request): AnonymousResourceCollection
2323
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
2424
]);
2525

26+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
27+
abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403);
28+
2629
$sources = CompetitorSource::where('space_id', $validated['space_id'])
2730
->orderByDesc('created_at')
2831
->paginate((int) ($validated['per_page'] ?? 20));
@@ -35,7 +38,16 @@ public function index(Request $request): AnonymousResourceCollection
3538
*/
3639
public function store(StoreCompetitorSourceRequest $request): JsonResponse
3740
{
38-
$source = CompetitorSource::create($request->validated());
41+
$validated = $request->validated();
42+
$spaceId = $validated['space_id'];
43+
44+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
45+
abort_if($currentSpace && $spaceId !== $currentSpace->id, 403);
46+
47+
$count = CompetitorSource::where('space_id', $spaceId)->count();
48+
abort_if($count >= 50, 422, 'Maximum 50 competitor sources per space');
49+
50+
$source = CompetitorSource::create($validated);
3951

4052
return response()->json(['data' => new CompetitorSourceResource($source)], 201);
4153
}
@@ -47,6 +59,9 @@ public function show(string $id): JsonResponse
4759
{
4860
$source = CompetitorSource::findOrFail($id);
4961

62+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
63+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
64+
5065
return response()->json(['data' => new CompetitorSourceResource($source)]);
5166
}
5267

@@ -56,6 +71,10 @@ public function show(string $id): JsonResponse
5671
public function update(UpdateCompetitorSourceRequest $request, string $id): JsonResponse
5772
{
5873
$source = CompetitorSource::findOrFail($id);
74+
75+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
76+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
77+
5978
$source->update($request->validated());
6079

6180
return response()->json(['data' => new CompetitorSourceResource($source)]);
@@ -67,6 +86,10 @@ public function update(UpdateCompetitorSourceRequest $request, string $id): Json
6786
public function destroy(string $id): JsonResponse
6887
{
6988
$source = CompetitorSource::findOrFail($id);
89+
90+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
91+
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);
92+
7093
$source->delete();
7194

7295
return response()->json(null, 204);

app/Http/Controllers/Api/DifferentiationController.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public function index(Request $request): AnonymousResourceCollection
2525
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
2626
]);
2727

28+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
29+
abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403);
30+
2831
$query = DifferentiationAnalysis::where('space_id', $validated['space_id'])
2932
->with('competitorContent')
3033
->orderByDesc('analyzed_at');
@@ -54,6 +57,9 @@ public function show(string $id): JsonResponse
5457
{
5558
$analysis = DifferentiationAnalysis::with('competitorContent')->findOrFail($id);
5659

60+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
61+
abort_if($currentSpace && $analysis->space_id !== $currentSpace->id, 403);
62+
5763
return response()->json(['data' => new DifferentiationAnalysisResource($analysis)]);
5864
}
5965

@@ -67,6 +73,9 @@ public function summary(Request $request): JsonResponse
6773
'space_id' => ['required', 'string'],
6874
]);
6975

76+
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
77+
abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403);
78+
7079
/** @var object{total_analyses: int|string, avg_differentiation_score: float|string|null, avg_similarity_score: float|string|null, max_differentiation_score: float|string|null, min_differentiation_score: float|string|null, last_analyzed_at: string|null}|null $summary */
7180
$summary = DifferentiationAnalysis::where('space_id', $validated['space_id'])
7281
->selectRaw('

app/Http/Requests/StoreCompetitorAlertRequest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Requests;
44

5+
use App\Rules\ExternalUrl;
56
use Illuminate\Foundation\Http\FormRequest;
67

78
class StoreCompetitorAlertRequest extends FormRequest
@@ -27,8 +28,8 @@ public function rules(): array
2728
'notify_channels' => ['nullable', 'array'],
2829
'notify_channels.email' => ['sometimes', 'array'],
2930
'notify_channels.email.*' => ['email'],
30-
'notify_channels.slack_webhook' => ['sometimes', 'url', 'max:2048'],
31-
'notify_channels.webhook_url' => ['sometimes', 'url', 'max:2048'],
31+
'notify_channels.slack_webhook' => ['sometimes', 'url', 'max:2048', new ExternalUrl],
32+
'notify_channels.webhook_url' => ['sometimes', 'url', 'max:2048', new ExternalUrl],
3233
];
3334
}
3435
}

app/Http/Requests/StoreCompetitorSourceRequest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Requests;
44

5+
use App\Rules\ExternalUrl;
56
use Illuminate\Foundation\Http\FormRequest;
67

78
class StoreCompetitorSourceRequest extends FormRequest
@@ -17,8 +18,8 @@ public function rules(): array
1718
return [
1819
'space_id' => ['required', 'string', 'exists:spaces,id'],
1920
'name' => ['required', 'string', 'max:255'],
20-
'url' => ['required', 'url', 'max:2048'],
21-
'feed_url' => ['nullable', 'url', 'max:2048'],
21+
'url' => ['required', 'url', 'max:2048', new ExternalUrl],
22+
'feed_url' => ['nullable', 'url', 'max:2048', new ExternalUrl],
2223
'crawler_type' => ['required', 'in:rss,sitemap,scrape,api'],
2324
'config' => ['nullable', 'array'],
2425
'is_active' => ['boolean'],

app/Http/Requests/UpdateCompetitorSourceRequest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Requests;
44

5+
use App\Rules\ExternalUrl;
56
use Illuminate\Foundation\Http\FormRequest;
67

78
class UpdateCompetitorSourceRequest extends FormRequest
@@ -16,8 +17,8 @@ public function rules(): array
1617
{
1718
return [
1819
'name' => ['sometimes', 'string', 'max:255'],
19-
'url' => ['sometimes', 'url', 'max:2048'],
20-
'feed_url' => ['nullable', 'url', 'max:2048'],
20+
'url' => ['sometimes', 'url', 'max:2048', new ExternalUrl],
21+
'feed_url' => ['nullable', 'url', 'max:2048', new ExternalUrl],
2122
'crawler_type' => ['sometimes', 'in:rss,sitemap,scrape,api'],
2223
'config' => ['nullable', 'array'],
2324
'is_active' => ['sometimes', 'boolean'],

0 commit comments

Comments
 (0)