security: Host-Whitelist & Legacy-API-Key-Übergabe härten (H5, M11)

- trustHosts erlaubt nur die konfigurierten Domains (plus localhost/IP
  für Health-Checks) und verwirft fremde Host-Header, sodass
  X-Forwarded-Host keine URL-Generierung mehr vergiften kann (H5).
- Legacy-API-Keys nur noch per Header (Bearer/X-ApiKey); die alte
  Query-/Body-Übergabe wird nicht mehr zur Auth akzeptiert, erhält aber
  weiterhin den 410-Migrationshinweis statt eines opaken 401 (M11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-14 13:48:28 +00:00
parent 6250b0bab4
commit 39e13d6d84
9 changed files with 207 additions and 21 deletions

View file

@ -37,7 +37,12 @@ function parityApiUser(bool $immediatePublication = true): User
$user->givePermissionTo('api:immediate-publication');
}
Sanctum::actingAs($user);
Sanctum::actingAs($user, [
'press-releases:read',
'press-releases:write',
'press-release-images:write',
'companies:read',
]);
return $user;
}
@ -45,7 +50,7 @@ function parityApiUser(bool $immediatePublication = true): User
test('the full connektar flow works like the old api with immediate publishing', function () {
/** @var TestCase $this */
Queue::fake();
Storage::fake('public');
Storage::fake('local');
$user = parityApiUser();
// 1. Firma anlegen — verschachtelte Alt-Params, 201 + Location + {}.
@ -120,7 +125,10 @@ test('the full connektar flow works like the old api with immediate publishing',
->assertJsonStructure(['id', 'title', 'text', 'teaser_end', 'language', 'category_id', 'view_url', 'company' => ['viewUrl', 'name']])
->assertJsonPath('company.name', 'Muster Energie GmbH');
expect(PressRelease::withoutGlobalScopes()->find($pressRelease->id)->hits)->toBe(1);
$trackedRelease = PressRelease::withoutGlobalScopes()->findOrFail($pressRelease->id);
expect($trackedRelease->hits)->toBe(1)
->and($trackedRelease->events()->where('type', 'view')->count())->toBe(1);
});
test('the admin switch routes legacy submissions through the ai review instead', function () {
@ -171,6 +179,32 @@ test('without the immediate publication permission legacy posts stay in review',
expect(PressRelease::withoutGlobalScopes()->latest('id')->first()->status)->toBe(PressReleaseStatus::Review);
});
test('legacy api html is sanitized before persistence', function () {
/** @var TestCase $this */
Queue::fake();
$user = parityApiUser(immediatePublication: false);
$company = Company::factory()->create(['portal' => Portal::Businessportal24, 'is_active' => true]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $company->id,
'category_id' => $category->id,
'title' => 'Bereinigter Legacy-Inhalt',
'text' => '<p>Erlaubter Text</p><script>alert(1)</script><a href="javascript:alert(2)">Link</a>',
],
])->assertCreated();
$text = PressRelease::withoutGlobalScopes()->latest('id')->value('text');
expect($text)
->toContain('<p>Erlaubter Text</p>')
->not->toContain('<script')
->not->toContain('javascript:');
});
test('legacy errors use the old global and field error format', function () {
/** @var TestCase $this */
parityApiUser();