Fix #200: Admin-Prüfung für alle Settings-Verwaltungsendpoints ergänzt
This commit is contained in:
parent
aa5ab7c06d
commit
9c6bb597d2
3 changed files with 114 additions and 0 deletions
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-24 – Fehlende Admin-Prüfung bei mehreren SettingsController-Endpoints (#200)
|
||||||
|
|
||||||
|
- **`src/Controllers/SettingsController.php`:** `deleteLogo()`, `llmTest()`, `bankImport()`, `createBankImportFormat()`, `updateBankImportField()`, `deleteBankImportFormat()`, `storeSuggestion()` und `dismissSuggestion()` prüften bislang nur den generellen Login (`Auth::require()` im Router-Gate), nicht die Rolle `admin` – anders als `update()`/`mailTest()`, deren Code-Kommentar diesen Bereich bewusst als admin-pflichtig begründet. Ein eingeloggter Benutzer mit Rolle `user` konnte damit das Firmenlogo löschen, CSV-Bankimport-Formate anlegen/ändern/löschen (Integritätsrisiko für importierte Buchungen) und den LLM-Verbindungstest auslösen. Alle acht Methoden rufen jetzt `Auth::requireAdmin()` konsistent zu Beginn auf.
|
||||||
|
- **`tests/e2e/specs/settings-admin-authorization.spec.js`** (neu): prüft auf HTTP-Ebene, dass ein Nicht-Admin-Account von den o.g. Endpoints mit 403 abgewiesen wird, während ein Admin weiterhin Zugriff hat.
|
||||||
|
|
||||||
## 2026-08-21 – scripts/ nach Ausführungsort aufgeräumt: Host / Container / intern (#196)
|
## 2026-08-21 – scripts/ nach Ausführungsort aufgeräumt: Host / Container / intern (#196)
|
||||||
|
|
||||||
- **`scripts/`:** Bislang lagen 16 Skripte mit vier verschiedenen Aufrufkonventionen flach nebeneinander – von außen war nicht erkennbar, was direkt vom Host aufrufbar ist, was `docker compose exec` braucht und was nie manuell laufen darf. Jetzt gibt es genau ein Ordnungskriterium: `scripts/` (Host, ausnahmslos direkt aufrufbar), `scripts/container/` (`docker compose exec web ...`) und `scripts/intern/` (Entrypoint/Container-Start, nie manuell).
|
- **`scripts/`:** Bislang lagen 16 Skripte mit vier verschiedenen Aufrufkonventionen flach nebeneinander – von außen war nicht erkennbar, was direkt vom Host aufrufbar ist, was `docker compose exec` braucht und was nie manuell laufen darf. Jetzt gibt es genau ein Ordnungskriterium: `scripts/` (Host, ausnahmslos direkt aufrufbar), `scripts/container/` (`docker compose exec web ...`) und `scripts/intern/` (Entrypoint/Container-Start, nie manuell).
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function deleteLogo(array $params): void
|
public function deleteLogo(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
Setting::deleteLogo();
|
Setting::deleteLogo();
|
||||||
View::redirect('/settings', 'Logo gelöscht.');
|
View::redirect('/settings', 'Logo gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +206,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function llmTest(array $params): void
|
public function llmTest(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
$result = (new \Steuerkram\Services\LlmService())->testConnection();
|
$result = (new \Steuerkram\Services\LlmService())->testConnection();
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
@ -211,6 +215,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function bankImport(array $params): void
|
public function bankImport(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
$formats = BankSource::all('csv');
|
$formats = BankSource::all('csv');
|
||||||
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
|
$suggestion = $_SESSION['bank_import_suggestion'] ?? null;
|
||||||
|
|
||||||
|
|
@ -226,6 +232,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function deleteBankImportFormat(array $params): void
|
public function deleteBankImportFormat(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
|
@ -242,6 +250,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function storeSuggestion(array $params): void
|
public function storeSuggestion(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
if (is_array($data)) {
|
if (is_array($data)) {
|
||||||
|
|
@ -255,6 +265,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function dismissSuggestion(array $params): void
|
public function dismissSuggestion(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
unset($_SESSION['bank_import_suggestion']);
|
unset($_SESSION['bank_import_suggestion']);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
|
|
@ -262,6 +274,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function createBankImportFormat(array $params): void
|
public function createBankImportFormat(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
|
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y'];
|
||||||
|
|
||||||
$name = trim($_POST['name'] ?? '');
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
|
@ -366,6 +380,8 @@ class SettingsController extends BaseController
|
||||||
|
|
||||||
public function updateBankImportField(array $params): void
|
public function updateBankImportField(array $params): void
|
||||||
{
|
{
|
||||||
|
Auth::requireAdmin();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$id = (int)($params['id'] ?? 0);
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
|
|
||||||
93
tests/e2e/specs/settings-admin-authorization.spec.js
Normal file
93
tests/e2e/specs/settings-admin-authorization.spec.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// @ts-check
|
||||||
|
// Fix #200: Mehrere SettingsController-Endpoints (Logo, LLM-Test,
|
||||||
|
// CSV-Bankimport-Formate) prüften Auth::requireAdmin() nicht, obwohl der
|
||||||
|
// Bereich laut Code-Kommentar bei update() bewusst admin-pflichtig sein
|
||||||
|
// soll. Ein normaler "user"-Account konnte damit Firmenlogo löschen,
|
||||||
|
// Bankimport-Formate anlegen/ändern/löschen und den LLM-Test auslösen.
|
||||||
|
// Dieser Test prüft die Durchsetzung auf HTTP-Ebene, die die PHP-Suite
|
||||||
|
// nicht erreicht (Router-Guard, Auth::requireAdmin()).
|
||||||
|
|
||||||
|
const { test, expect } = require('@playwright/test')
|
||||||
|
const { uniqueTag } = require('../helpers/app')
|
||||||
|
|
||||||
|
test.describe('Autorisierung: Settings-Verwaltungsendpoints nur für Admins (Fix #200)', () => {
|
||||||
|
test('Nicht-Admin wird von Logo-, Bankimport- und LLM-Test-Endpoints ausgesperrt', async ({ page, browser }) => {
|
||||||
|
const username = uniqueTag('noadmin').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 64)
|
||||||
|
const password = 'TempPass123!'
|
||||||
|
|
||||||
|
await page.goto('/useradmin')
|
||||||
|
await page.fill('#field-username', username)
|
||||||
|
await page.fill('#field-password', password)
|
||||||
|
await page.click('#submit-btn')
|
||||||
|
await page.waitForURL('/useradmin')
|
||||||
|
|
||||||
|
const row = page.locator('tr', { hasText: username })
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
|
||||||
|
const userPage = await context.newPage()
|
||||||
|
await userPage.goto('/login')
|
||||||
|
await userPage.fill('#username', username)
|
||||||
|
await userPage.fill('#password', password)
|
||||||
|
await userPage.click('form[action="/login"] button[type="submit"]')
|
||||||
|
await userPage.waitForURL(url => !url.pathname.startsWith('/login'))
|
||||||
|
|
||||||
|
await userPage.goto('/settings')
|
||||||
|
const csrfToken = await userPage.evaluate(() => document.querySelector('meta[name="csrf-token"]')?.content || '')
|
||||||
|
expect(csrfToken).not.toBe('')
|
||||||
|
|
||||||
|
const headers = { 'X-CSRF-Token': csrfToken }
|
||||||
|
|
||||||
|
const bankImportGet = await userPage.request.get('/settings/bankimport')
|
||||||
|
expect(bankImportGet.status()).toBe(403)
|
||||||
|
|
||||||
|
const deleteLogo = await userPage.request.post('/settings/logo/delete', { headers, form: {} })
|
||||||
|
expect(deleteLogo.status()).toBe(403)
|
||||||
|
|
||||||
|
const llmTest = await userPage.request.post('/settings/llm-test', { headers, form: {} })
|
||||||
|
expect(llmTest.status()).toBe(403)
|
||||||
|
|
||||||
|
const createFormat = await userPage.request.post('/settings/bankimport/create', {
|
||||||
|
headers,
|
||||||
|
form: { name: 'X', kuerzel: 'X' },
|
||||||
|
})
|
||||||
|
expect(createFormat.status()).toBe(403)
|
||||||
|
|
||||||
|
const suggest = await userPage.request.post('/settings/bankimport/suggest', {
|
||||||
|
headers,
|
||||||
|
data: {},
|
||||||
|
})
|
||||||
|
expect(suggest.status()).toBe(403)
|
||||||
|
|
||||||
|
const dismiss = await userPage.request.post('/settings/bankimport/dismiss-suggestion', { headers, form: {} })
|
||||||
|
expect(dismiss.status()).toBe(403)
|
||||||
|
|
||||||
|
const updateField = await userPage.request.post('/settings/bankimport/1/field', {
|
||||||
|
headers,
|
||||||
|
form: { field: 'name', value: 'X' },
|
||||||
|
})
|
||||||
|
expect(updateField.status()).toBe(403)
|
||||||
|
|
||||||
|
const deleteFormat = await userPage.request.post('/settings/bankimport/1/delete', { headers, form: {} })
|
||||||
|
expect(deleteFormat.status()).toBe(403)
|
||||||
|
|
||||||
|
for (const response of [bankImportGet, deleteLogo, llmTest, createFormat, suggest, dismiss, updateField, deleteFormat]) {
|
||||||
|
await expect(response.text()).resolves.toContain('Kein Zugriff')
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close()
|
||||||
|
} finally {
|
||||||
|
const delForm = row.locator('form[action$="/delete"]')
|
||||||
|
if (await delForm.count() > 0) {
|
||||||
|
await delForm.locator('button[type="submit"]').click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Admin darf /settings/bankimport weiterhin aufrufen (kein 403)', async ({ page }) => {
|
||||||
|
const response = await page.goto('/settings/bankimport')
|
||||||
|
expect(response.status()).not.toBe(403)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue