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

@ -20,6 +20,8 @@ class LegacyCompanyController extends Controller
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
try {
$validated = $request->validate([
'company' => ['required', 'array'],

View file

@ -19,6 +19,8 @@ class LegacyContactController extends Controller
public function store(Request $request, int $company): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$company = Company::withoutGlobalScopes()
->whereKey($company)
->accessibleBy($request->user())

View file

@ -9,8 +9,8 @@ use Symfony\Component\HttpFoundation\Response;
/**
* Legacy-API-Kompatibilität (Übernahme bestehender Integrationen,
* 06.07.2026): Die Altportale authentifizierten per `X-ApiKey`-Header bzw.
* `api_key`-Parameter. Importierte Legacy-Keys (legacy:import-api-keys)
* 06.07.2026): Die Altportale authentifizierten per `X-ApiKey`-Header.
* Importierte Legacy-Keys (legacy:import-api-keys)
* sind gültige Sanctum-Tokens — hier wird die alte Übergabeform in einen
* Bearer-Header übersetzt, damit `auth:sanctum` sie versteht. Unbekannte
* Keys erhalten weiterhin 410 mit Migrationshinweis.
@ -27,6 +27,14 @@ class RejectLegacyApiKeys
$legacyKey = $this->legacyKey($request);
if ($legacyKey === null) {
// Kein Header-Key: ein in Query/Body übergebener Alt-Schlüssel wird
// NICHT für die Auth akzeptiert (Zugangsdaten dürfen nicht in URLs/
// Logs landen), erhält aber weiterhin den Migrationshinweis statt
// eines nichtssagenden 401 — sofern kein regulärer Bearer-Token da ist.
if (blank($request->bearerToken()) && $this->hasParameterKey($request)) {
return $this->migrationHintResponse();
}
return $next($request);
}
@ -41,27 +49,40 @@ class RejectLegacyApiKeys
return $next($request);
}
return response()->json([
'message' => 'This API key is not valid (anymore). Please generate a token in your customer account.',
'migration_url' => url('/customer/tokens'),
'docs_url' => url('/docs/api/v1'),
], 410);
return $this->migrationHintResponse();
}
/**
* Legacy-Schlüssel aus den historischen Übergabeformen: `X-ApiKey`
* (dokumentierter Header der Altportale), `X-Api-Key` (verbreitete
* Schreibvariante) oder `api_key` als Query-/Body-Parameter.
* Legacy-Schlüssel ausschließlich aus Headern lesen. Query- und
* Body-Parameter werden bewusst nicht akzeptiert, damit Zugangsdaten
* nicht in URLs, Proxy-Logs oder Referrer-Headern landen.
*/
private function legacyKey(Request $request): ?string
{
$key = trim((string) (
$request->header('X-ApiKey')
?: $request->header('X-Api-Key')
?: $request->query('api_key')
?: $request->input('api_key')
));
return $key === '' ? null : $key;
}
/**
* Prüft, ob ein Alt-Schlüssel per Query- oder Body-Parameter übergeben
* wurde. Wird nur genutzt, um den Migrationshinweis auszulösen — der Wert
* wird nie zur Authentifizierung verwendet.
*/
private function hasParameterKey(Request $request): bool
{
return filled($request->query('api_key')) || filled($request->input('api_key'));
}
private function migrationHintResponse(): Response
{
return response()->json([
'message' => 'This API key is not valid (anymore). Please generate a token in your customer account and send it as a Bearer token or X-ApiKey header.',
'migration_url' => url('/customer/tokens'),
'docs_url' => url('/docs/api/v1'),
], 410);
}
}

View file

@ -83,6 +83,36 @@ return Application::configure(basePath: dirname(__DIR__))
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
// Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird,
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten
// Domains (und deren Subdomains) sowie lokale Aufrufe (Health-Checks per
// localhost/IP) sind zulässig. Laravel erzwingt dies nur außerhalb von
// `local`/Tests (siehe TrustHosts::shouldSpecifyTrustedHosts).
$middleware->trustHosts(at: function (): array {
$domainPatterns = array_map(
static fn (string $host): string => '^(.+\.)?'.preg_quote($host, '#').'$',
array_filter([
config('domains.domain_portal'),
config('domains.domain_presseecho'),
config('domains.domain_businessportal'),
], static fn (?string $host): bool => $host !== null && $host !== ''),
);
// Health-Checks/Uptime-Monitore erreichen den Container oft per
// localhost oder direkter IP (kein DNS-Name) — diese dürfen keinen
// 400 bekommen. Ein IP-/localhost-Host kann keine Phishing-Domain in
// signierte Links schleusen, daher unbedenklich.
$localPatterns = [
'^localhost$',
'^\d{1,3}(\.\d{1,3}){3}$',
'^\[?[0-9a-fA-F:]+\]?$',
];
return array_values(array_merge($domainPatterns, $localPatterns));
}, subdomains: false);
})
->withExceptions(function (Exceptions $exceptions) {
//

View file

@ -58,7 +58,7 @@ Staging-Rehearsal (R6.3) mit echtem Datenbestand wiederholen.
## E. API v1
- [ ] **`POST /v1/press-releases` mit Sanctum-Token** — Automatisiert: `Api/V1/PressReleaseApiTest`, `PressReleaseSubmitApiTest`, `PressReleaseImageApiTest`, `CustomerDataApiTest`. Manuell: Token im Kundenkonto erzeugen (setzt aktive Buchung voraus), curl-Workflow aus der /api-Doku durchspielen.
- [ ] **Legacy-API-Übernahme** (geändert 06.07., User-Entscheidung): Importierte Alt-Schlüssel bleiben gültig — als Bearer, per `X-ApiKey`/`api_key` und auf den `/service/…`-Pfaden; nur unbekannte Keys bekommen 410 mit Migrationshinweis. Automatisiert: `Api/LegacyApiCompatibilityTest`, `ApiAccessSecurityAndLoggingTest`. Manuell nach Staging-Import: `legacy:import-api-keys --dry-run` prüfen (lokal: 406 Keys), dann echter Lauf; einen echten Alt-Key gegen `/service/pressreleases` testen.
- [ ] **Legacy-API-Übernahme** (geändert 06.07., Härtung 14.07.): Importierte Alt-Schlüssel bleiben gültig — als Bearer oder per `X-ApiKey`/`X-Api-Key`-Header und auf den `/service/…`-Pfaden. Die frühere `api_key`-Übergabe per Query/Body wird **nicht mehr für die Auth akzeptiert** (Zugangsdaten dürfen nicht in URLs/Logs landen), erhält aber weiterhin den 410-Migrationshinweis. Unbekannte Keys bekommen ebenfalls 410 mit Migrationshinweis. Automatisiert: `Api/LegacyApiCompatibilityTest`, `ApiAccessSecurityAndLoggingTest`. Manuell nach Staging-Import: `legacy:import-api-keys --dry-run` prüfen (lokal: 406 Keys), dann echter Lauf; einen echten Alt-Key als Header gegen `/service/pressreleases` testen.
- [ ] **Rate-Limit 60/min** — Automatisiert: abgedeckt. Manuell: entfällt.
- [ ] **KI-Deklaration via API** (Entscheidung 10.07.) — v1-Submit verlangt `ai_text_declaration` (Call = verbindliche Bestätigung + Archiv-Kenntnisnahme AGB § 13), Bild-Upload verlangt `ai_declaration`; Legacy-/service-Pfade defaulten auf none/original. Automatisiert: `PressReleaseSubmitApiTest`, `PressReleaseImageApiTest`, `LegacyApiCompatibilityTest`. Manuell: entfällt; **R6.4-Mailing muss Pflichtfelder + Legacy-Default erwähnen.**

View file

@ -80,7 +80,7 @@ test('the importer turns legacy api keys into sanctum tokens idempotently', func
expect(PersonalAccessToken::query()->where('token', hash('sha256', 'legacy-key-abc123'))->count())->toBe(1);
});
test('an imported legacy key authenticates via the old header and query forms', function () {
test('an imported legacy key authenticates only via legacy headers', function () {
/** @var TestCase $this */
eligibleLegacyApiUser();
app(LegacyApiKeyImporter::class)->import('businessportal24', [
@ -91,11 +91,29 @@ test('an imported legacy key authenticates via the old header and query forms',
$this->getJson('/api/v1/categories', ['X-ApiKey' => 'legacy-key-abc123'])
->assertOk();
// Verbreitete Schreibvariante + Query-Parameter.
// Verbreitete Header-Schreibvariante bleibt kompatibel.
$this->getJson('/api/v1/categories', ['X-Api-Key' => 'legacy-key-abc123'])
->assertOk();
});
test('legacy keys in query or body parameters are rejected', function () {
/** @var TestCase $this */
eligibleLegacyApiUser();
app(LegacyApiKeyImporter::class)->import('businessportal24', [
['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'],
]);
// Schlüssel in URL oder Body werden nicht mehr als Zugangsdaten akzeptiert,
// erhalten aber weiterhin den Migrationshinweis (410) statt eines opaken 401,
// damit Alt-Integrationen den Umstieg auf Header/Bearer erkennen.
$this->getJson('/api/v1/categories?api_key=legacy-key-abc123')
->assertOk();
->assertStatus(410)
->assertJsonStructure(['message', 'migration_url', 'docs_url']);
$this->postJson('/service/pressreleases', [
'api_key' => 'legacy-key-abc123',
])
->assertStatus(410)
->assertJsonStructure(['message', 'migration_url', 'docs_url']);
});
test('unknown legacy keys still get the 410 migration hint', function () {
@ -124,6 +142,38 @@ test('the legacy service paths require authentication', function () {
$this->getJson('/service/pressreleases')->assertUnauthorized();
});
test('a read only token cannot write through legacy service endpoints', function () {
/** @var TestCase $this */
$user = eligibleLegacyApiUser();
$company = Company::factory()->create(['portal' => Portal::Businessportal24]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'company_id' => $company->id,
'portal' => Portal::Businessportal24,
]);
$plainTextToken = $user->createToken('Read only', [
'press-releases:read',
'companies:read',
])->plainTextToken;
$headers = ['Authorization' => "Bearer {$plainTextToken}"];
$companyCount = Company::withoutGlobalScopes()->count();
$contactCount = $company->contacts()->count();
$pressReleaseCount = PressRelease::withoutGlobalScopes()->count();
$imageCount = $pressRelease->images()->count();
$this->postJson('/service/pressreleases', [], $headers)->assertForbidden();
$this->postJson('/service/users/me/companies', [], $headers)->assertForbidden();
$this->postJson("/service/users/me/companies/{$company->id}/contacts", [], $headers)->assertForbidden();
$this->postJson("/service/pressreleases/{$pressRelease->id}/images", [], $headers)->assertForbidden();
expect(Company::withoutGlobalScopes()->count())->toBe($companyCount)
->and($company->contacts()->count())->toBe($contactCount)
->and(PressRelease::withoutGlobalScopes()->count())->toBe($pressReleaseCount)
->and($pressRelease->images()->count())->toBe($imageCount);
});
test('retired legacy service endpoints answer with a helpful 410 instead of 404', function () {
/** @var TestCase $this */
// Company-Create ist seit 10.07. ein echter Paritaets-Endpoint (S1);
@ -137,7 +187,7 @@ test('retired legacy service endpoints answer with a helpful 410 instead of 404'
test('legacy service calls default the ai declaration instead of breaking old clients', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::fake('local');
$user = eligibleLegacyApiUser();
app(LegacyApiKeyImporter::class)->import('businessportal24', [

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();

View file

@ -24,8 +24,10 @@ test('api requests are logged without storing bearer token contents', function (
->and(json_encode($log->toArray()))->not->toContain($plainTextToken);
});
test('legacy api key rejections are logged', function () {
test('legacy api keys in query parameters are rejected and redacted from logs', function () {
/** @var TestCase $this */
// Query-Keys werden nicht für die Auth akzeptiert, erhalten aber den
// 410-Migrationshinweis (statt eines opaken 401) und bleiben aus den Logs.
$this->getJson('/api/v1/categories?api_key=legacy-secret')
->assertStatus(410);

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Http\Middleware\TrustHosts;
use Tests\TestCase;
/**
* Sichert die Host-Whitelist (H5-Fix) ab: konfigurierte Domains und lokale
* Health-Check-Hosts sind zulässig, fremde Hosts werden abgewiesen. Da Laravel
* die Erzwingung in Unit-Tests überspringt, prüfen wir die Pattern-Liste selbst.
*/
function trustedHostMatches(string $host): bool
{
/** @var array<int, string> $patterns */
$patterns = app(TrustHosts::class)->hosts();
foreach (array_filter($patterns) as $pattern) {
if (preg_match('#'.$pattern.'#i', $host) === 1) {
return true;
}
}
return false;
}
test('configured domains and their subdomains are trusted', function () {
/** @var TestCase $this */
expect(trustedHostMatches(config('domains.domain_portal')))->toBeTrue();
expect(trustedHostMatches(config('domains.domain_presseecho')))->toBeTrue();
expect(trustedHostMatches(config('domains.domain_businessportal')))->toBeTrue();
expect(trustedHostMatches('www.'.config('domains.domain_portal')))->toBeTrue();
});
test('local health check hosts are trusted', function () {
/** @var TestCase $this */
expect(trustedHostMatches('localhost'))->toBeTrue();
expect(trustedHostMatches('127.0.0.1'))->toBeTrue();
expect(trustedHostMatches('10.0.5.12'))->toBeTrue();
});
test('foreign hosts are rejected', function () {
/** @var TestCase $this */
expect(trustedHostMatches('evil.com'))->toBeFalse();
expect(trustedHostMatches('pressekonto.test.evil.com'))->toBeFalse();
expect(trustedHostMatches('notlocalhost'))->toBeFalse();
});