Skip to content

Security hardening + OPAC fixes (v0.7.42)#294

Merged
fabiodalez-dev merged 6 commits into
mainfrom
security/scan-findings-20260725
Jul 26, 2026
Merged

Security hardening + OPAC fixes (v0.7.42)#294
fabiodalez-dev merged 6 commits into
mainfrom
security/scan-findings-20260725

Conversation

@fabiodalez-dev

@fabiodalez-dev fabiodalez-dev commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

Release-prep branch for v0.7.42 — a security-hardening + bug-fix release. It closes a set of access-control, SSRF, open-redirect and data-exposure issues found in a full security audit of app/, public/ and installer/, and fixes three OPAC/CMS regressions reported by users.

Security (audit findings)

High

  • Access control on internal APIsGET /api/editori and GET /api/libri were reachable without a session and returned every column, exposing publisher PII (email, phone, tax code, referent contacts) and internal book fields (price, private notes, inventory numbers). Now behind the admin-session middleware, matching how the API docs already describe them.
  • Borrower privacy — a non-admin patron could open /admin/books/{id} and read every borrower's name, email and loan history. Borrower identity is now gated to admin/staff only.
  • Update privilege boundary — a staff user could upload+install an update package or trigger a full update over the app files (a route to code execution). Those actions (and saveToken) now require an explicit admin re-check.

Medium/Low — public search API no longer leaks borrower name/email; admin routes re-validate role+status against the DB each request (immediate demotion/suspension); settings no longer expose API keys / reCAPTCHA secret to staff; API keys accepted only via header, never ?api_key=; open redirect after login and on the language switch (/\ backslash bypass) fixed in all sanitizers; path-traversal on language-file upload; SSRF in the image proxy (redirect + DNS-rebinding) and the installer DB test; plaintext SMTP password in the installer; CSV/formula injection in two exports; reset-link Host-header poisoning; login timing side channel; rate-limit bypass via forged forwarding headers.

Two independent Opus reviewers audited the fixes; the one real regression they found (a staff save wiping the admin reCAPTCHA secret) and two minor residuals are fixed in the final commit.

Bug fixes

Database

  • migrate_0.7.42.sql — bumps the da_DK translation key count (6607 → 6611) for installs already on 0.7.41 (idempotent). New behavioural migration test.

Testing

  • PHPStan level 5 clean; full unit battery green (incl. new migration-0.7.42 and security-scan-findings tests); code-quality 15/15. The related-books and subtitle fixes were verified in a real browser at 5 viewport widths. Full E2E suite + reinstall regression to run before release.

Summary by CodeRabbit

  • Nuove funzionalità

    • Rafforzati i controlli di accesso per API, pagine amministrative e operazioni di aggiornamento.
    • Limitata la visualizzazione dei dati personali dei prestatori a amministratori e staff.
    • Le API accettano le chiavi solo tramite header.
    • Aggiunta protezione avanzata per password SMTP e link di reimpostazione.
  • Correzioni

    • Migliorata la sicurezza contro redirect indesiderati, SSRF, traversal e spoofing degli indirizzi IP.
    • Corretta la visualizzazione dei sottotitoli dei libri e della griglia dei libri correlati.
    • Migliorato l’upload dell’immagine Hero con policy CSP restrittive.
  • Documentazione

    • Aggiornate le note di rilascio e la documentazione API per la versione 0.7.42.

…orage

Six findings from a multi-agent security scan of app/, public/ and installer/,
each confirmed against the code and covered by a regression test
(tests/security-scan-findings.unit.php, 12/12):

- F1 (CWE-807, MEDIUM): RateLimitMiddleware keyed the login/forgot/reset
  throttle on client-supplied X-Forwarded-For without checking the peer, so a
  rotating header minted unlimited buckets and defeated the only brute-force
  control. It now honors forwarding headers only when REMOTE_ADDR is a
  configured trusted proxy (reuses HtmlHelper::isRemoteAddrTrustedProxy, made
  public); default Apache-direct keys on the real peer.
- F6 (CWE-807): same trusted-proxy gate applied to RememberMeService::getClientIP
  so the security audit log / session UI cannot be fed a forged IP.
- F3 (CWE-862): private mode allow-listed /feed.xml, /sitemap and /llms.txt,
  leaking book titles/authors/descriptions and enumerating every catalog URL to
  logged-out visitors. Removed from ALLOWED_PREFIXES (still fully public when
  private mode is off).
- F5 (CWE-918): /api/plugins/proxy-image followed redirects and re-resolved DNS
  after its own public-IP check (TOCTOU rebinding). Now FOLLOWLOCATION=false
  (3xx refused), protocol pinned to HTTPS, and curl pinned via CURLOPT_RESOLVE
  to the exact IPs already validated as public.
- F2 (CWE-312): the installer wrote smtp_password to system_settings in
  cleartext, bypassing the app's AES-256-GCM at-rest encryption. It now encrypts
  with the same scheme inline (round-trips through SettingsEncryption::decrypt);
  aborts the step if no key is available rather than persisting plaintext.
- F4 (CWE-918): the installer DB connection test echoed the raw PDO error,
  an error-based oracle for internal port scanning on the unauthenticated
  test-connection path. Detail now goes to the server log; the client gets a
  generic, non-oracle message.

phpstan level 5 clean; full unit battery 67/67.
…trict CSP

Three OPAC/CMS regressions reported on 2026-07-25/26:

- Related books grid packed 6+ cards on high-resolution laptops. The
  auto-fill minmax(170px) floor let a wide grid fit too many columns.
  Each column is now at least a quarter of the row (or 200px), so the
  grid caps at 4 columns on wide screens and degrades to 3/2/1 as the
  viewport narrows; the <=480px swipe carousel is unchanged. Verified in
  a real browser at 1512/1100/834/640/390px -> 4/4/3/2/carousel.

- The book subtitle (sottotitolo) was never rendered on the public book
  page even when present (#293). Added it under the title, escaped the
  same way, shown only when non-empty. Verified it now displays.

- Hero background upload failed under a strict connect-src CSP (#292):
  the file-added handler did fetch(URL.createObjectURL(file.data)), and
  blob: is not always allowed by connect-src, so the image never reached
  the form. Uppy already provides a File in file.data — use it directly
  (mirrors settings/index.php's logo uploader); only fall back to
  fetching a preview URL when file.data is not a File. No blob: fetch on
  the normal path, so it works regardless of the site's CSP.
Findings from a multi-agent security audit of app/, public/ and installer/,
each confirmed against the code and fixed under the project's conventions
(soft-delete, escaping, \Throwable, admin re-checks). HIGH first:

- F1/F5 (CWE-862): GET /api/editori and /api/libri were unauthenticated and
  returned SELECT e.*/l.* — publisher PII (email, phone, tax code, referent
  contacts) and internal book fields (price, private notes, inventory numbers).
  Both routes now require AdminAuthMiddleware, matching the sibling admin API
  routes and the API docs.
- F2 (CWE-862): a standard/premium patron could read every borrower's name,
  email and loan history via /admin/books/{id}. Borrower PII is now not fetched
  for non-admin/staff and gated behind $isAdminOrStaff in the view (defaults to
  false so an unset flag can never leak PII).
- F3/F7 (CWE-269): a staff user could upload+install an update package or run a
  full update over the app files (path to RCE). installManualUpdate/uploadUpdate/
  performUpdate now require an inline admin re-check, like backup/restore.
- F8 (CWE-613): admin routes re-validate tipo_utente + stato against the DB each
  request (cached per request), so demotion/suspension takes effect immediately.
- F9 (CWE-863): the public search API no longer returns the borrower's name/
  email — availability and due date only.
- F11 (CWE-522): settings no longer expose API keys or the reCAPTCHA secret to
  staff; F12 (CWE-598): API keys accepted only via X-API-Key / Authorization
  header, never a ?api_key= query string.
- F4/F10 (CWE-601): open redirect after login and on the language switch — the
  `/\` backslash-authority bypass of the `//` guard is now rejected.
- F6 (CWE-22): path traversal in language-file upload — the locale code is
  validated and the resolved path confined before any file write.
- SSRF: image proxy no longer follows redirects and pins curl to the validated
  public IPs (DNS-rebinding); installer DB test no longer echoes the raw PDO
  error (network oracle). F13: reset links fail closed on the Host header.
  F14: login dummy hash matches PASSWORD_DEFAULT cost (timing). F15/F16: CSV/TSV
  exports escape formula-leading cells. F1(prev)/F6(prev): rate-limit + audit IP
  only trust forwarding headers behind a configured proxy.

Release prep for v0.7.42:
- 4 new UI strings added to all 5 locales; da_DK key count 6607 -> 6611.
- migrate_0.7.42.sql bumps existing 0.7.41 installs to the new da_DK count
  (idempotent); migrate_0.7.41.sql updated in lockstep with the locale file.
- New behavioural migration test (tests/migration-0.7.42.unit.php, 5/5).
- version.json -> 0.7.42; README "What's New" section; docs/api.MD updated.

phpstan level 5 clean; full unit battery 68/68; code-quality 15/15.
…d, redirect

Two Opus reviewers (correctness + regression) audited the audit fixes and
agreed on one real regression the F11 fix introduced, plus two minor residuals:

- F11 regression (was HIGH): the reCAPTCHA secret input is rendered disabled
  for staff, and disabled inputs are not submitted — so updateContactSettings
  read the absent field as '' and persisted it, silently WIPING the
  admin-configured secret and fail-opening reCAPTCHA on the public contact
  form. Now the secret key is admin-only in the save handler: for non-admins
  it is dropped from the settings map, so neither a normal staff save nor a
  forged POST can overwrite the stored value.
- F3/F7 residual (LOW): saveToken() (persists the GitHub update token) lacked
  the inline admin re-check its sibling update actions have — added.
- PrestitiController::sanitizeRedirect (LOW): only blocked '//', not the '/\'
  backslash-authority bypass; aligned with the AuthController/LanguageController
  sanitizers (findings F4/F10). Admin+CSRF-gated, so not externally exploitable,
  fixed for consistency.

Everything else in the audit was verified complete by both reviewers.
phpstan level 5 clean.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fabiodalez-dev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 29ca1b05-c786-4a0c-96c5-65337fe29991

📥 Commits

Reviewing files that changed from the base of the PR and between 6c6743d and 960434e.

📒 Files selected for processing (5)
  • app/Controllers/PasswordController.php
  • app/Middleware/AdminAuthMiddleware.php
  • app/Routes/web.php
  • app/Views/settings/contacts-tab.php
  • tests/extra-features.spec.js
📝 Walkthrough

Walkthrough

La release 0.7.42 introduce hardening su autorizzazioni, privacy, redirect, API key, proxy, rate limiting e segreti. Aggiorna inoltre installer, migrazione danese, test di regressione, interfacce libro/homepage, documentazione e versione.

Changes

Hardening e release 0.7.42

Layer / File(s) Summary
Autorizzazioni e credenziali
app/Middleware/*, app/Routes/web.php, app/Controllers/SettingsController.php, app/Controllers/UpdateController.php, app/Views/settings/*, docs/api.MD, locale/*
La verifica dei ruoli viene rieseguita dal database; le route sensibili e gli aggiornamenti richiedono ruoli appropriati; le API key passano solo da X-API-Key o Authorization: Bearer.
Privacy dei prestiti e delle API
app/Controllers/LibriController.php, app/Controllers/PublicApiController.php, app/Views/libri/scheda_libro.php
I dati personali dei borrower sono mostrati solo ad admin/staff e la Public API restituisce esclusivamente lo stato del prestito.
Protezione richieste e rete
app/Controllers/AuthController.php, app/Controllers/PasswordController.php, app/Controllers/*Controller.php, app/Routes/web.php, app/Middleware/*, app/Support/*, tests/security-scan-findings.unit.php
Sono rafforzati i controlli contro open redirect, timing side-channel, reset-link avvelenati, SSRF/DNS rebinding e spoofing degli IP tramite forwarding header.
Filesystem, segreti ed export
app/Controllers/Admin/LanguagesController.php, app/Controllers/CollocazioneController.php, app/Controllers/LibraryThingImportController.php, installer/classes/Validator.php, installer/steps/step6.php
Gli upload validano locale e percorso, gli export impostano il quoting, gli errori DB sono generalizzati e le password SMTP vengono cifrate prima della persistenza.
Interfaccia, migrazione e release
app/Views/cms/edit-home.php, app/Views/frontend/book-detail.php, installer/database/migrations/*, tests/migration-0.7.42.unit.php, README.md, version.json
Sono aggiornati upload hero, sottotitolo e griglia dei libri correlati; la migrazione 0.7.42 riallinea da_DK in modo idempotente e la versione passa a 0.7.42.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: fabiodalez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Il titolo riassume bene l’insieme di hardening di sicurezza e fix OPAC della release v0.7.42.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/scan-findings-20260725

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 56 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Controllers/LibriController.php (1)

425-428: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Riduci lo stesso PII anche su $copySchedule.

getScheduleByBookId() restituisce utente_id, utente_nome, utente_cognome e utente_email; il calendario costruisce gli eventi da $copySchedule, non solo da $copie. Applica lo stesso blocco unset() anche per $copySchedule quando $isAdminOrStaff è falso.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Controllers/LibriController.php` around lines 425 - 428, Applica a
$copySchedule lo stesso blocco di rimozione dei dati personali già usato per
$copie quando $isAdminOrStaff è falso. Nel flusso del controller, rimuovi da
ogni elemento di $copySchedule i campi utente_id, utente_nome, utente_cognome e
utente_email prima che il calendario costruisca gli eventi.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/Controllers/PasswordController.php`:
- Around line 100-107: Replace the error_log call in the EmailService fallback
within the password email flow with SecureLogger::error(), preserving the
failure context and exception details while using the centralized redaction and
retention behavior.

In `@app/Middleware/AdminAuthMiddleware.php`:
- Around line 81-90: La revalidazione del ruolo deve aggiornare la sessione e i
controlli successivi devono usare il ruolo ri-validato. In
app/Middleware/AdminAuthMiddleware.php alle righe 81-90, modifica
revalidateRole() affinché sincronizzi $_SESSION['user']['tipo_utente'] e stato
con i valori DB prima di restituire true; in
app/Controllers/UpdateController.php alle righe 75-79, 498-502, 591-595 e
660-665, mantieni i controlli di performUpdate, uploadUpdate,
installManualUpdate e saveToken, facendoli dipendere dal ruolo aggiornato
anziché dallo snapshot di login.
- Around line 168-263: In tutte le route che istanziano AdminAuthMiddleware,
passa la connessione mysqli condivisa dal container tramite
$app->getContainer()->get('db'). Aggiungi o aggiorna il costruttore di
AdminAuthMiddleware per ricevere e conservare questa dipendenza, lasciando
getDb() esclusivamente come fallback quando la connessione iniettata non è
disponibile.

In `@app/Routes/web.php`:
- Around line 2821-2849: Apply the same validated-IP DNS pinning to the
`/proxy/cover` curl block: collect the validated IPv4 and IPv6 addresses, reject
an empty collection, build the host/port/IP resolve entry, and pass it through
`CURLOPT_RESOLVE` alongside the existing HTTPS restriction. Update the
`/proxy/cover` curl configuration rather than the already protected block shown
by the diff.

In `@app/Views/settings/contacts-tab.php`:
- Around line 143-148: Update the recaptcha_secret_key input in the admin
contacts view to use the view’s required direct attribute escaping instead of
HtmlHelper::e($recaptchaSecret), while preserving the existing password field
value and surrounding markup.

---

Outside diff comments:
In `@app/Controllers/LibriController.php`:
- Around line 425-428: Applica a $copySchedule lo stesso blocco di rimozione dei
dati personali già usato per $copie quando $isAdminOrStaff è falso. Nel flusso
del controller, rimuovi da ogni elemento di $copySchedule i campi utente_id,
utente_nome, utente_cognome e utente_email prima che il calendario costruisca
gli eventi.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bad50b98-6bc5-44de-bbf2-c12b78e6639d

📥 Commits

Reviewing files that changed from the base of the PR and between d22834e and 6c6743d.

📒 Files selected for processing (37)
  • README.md
  • app/Controllers/Admin/LanguagesController.php
  • app/Controllers/AuthController.php
  • app/Controllers/CollocazioneController.php
  • app/Controllers/LanguageController.php
  • app/Controllers/LibraryThingImportController.php
  • app/Controllers/LibriController.php
  • app/Controllers/PasswordController.php
  • app/Controllers/PrestitiController.php
  • app/Controllers/PublicApiController.php
  • app/Controllers/SettingsController.php
  • app/Controllers/UpdateController.php
  • app/Middleware/AdminAuthMiddleware.php
  • app/Middleware/ApiKeyMiddleware.php
  • app/Middleware/PrivateModeMiddleware.php
  • app/Middleware/RateLimitMiddleware.php
  • app/Routes/web.php
  • app/Support/HtmlHelper.php
  • app/Support/RememberMeService.php
  • app/Views/cms/edit-home.php
  • app/Views/frontend/book-detail.php
  • app/Views/libri/scheda_libro.php
  • app/Views/settings/advanced-tab.php
  • app/Views/settings/contacts-tab.php
  • docs/api.MD
  • installer/classes/Validator.php
  • installer/database/migrations/migrate_0.7.41.sql
  • installer/database/migrations/migrate_0.7.42.sql
  • installer/steps/step6.php
  • locale/da_DK.json
  • locale/de_DE.json
  • locale/en_US.json
  • locale/fr_FR.json
  • locale/it_IT.json
  • tests/migration-0.7.42.unit.php
  • tests/security-scan-findings.unit.php
  • version.json

Comment thread app/Controllers/PasswordController.php
Comment thread app/Middleware/AdminAuthMiddleware.php
Comment thread app/Middleware/AdminAuthMiddleware.php
Comment thread app/Routes/web.php
Comment thread app/Views/settings/contacts-tab.php
CodeRabbit's full review surfaced a real gap the Opus reviewers missed, plus
three quick wins:

- AdminAuthMiddleware: revalidateRole() read tipo_utente/stato from the DB but
  discarded them, leaving $_SESSION['user']['tipo_utente'] at the stale
  login-time value. Since the middleware admits both admin and staff, a demoted
  admin (admin -> staff) passed the set-membership check AND kept 'admin' in the
  session, so every downstream inline 'admin'-only guard (update install/perform,
  saveToken, reCAPTCHA secret) still granted the destructive op. Now the fresh
  DB role/status is written back to the session, so demotion takes full effect
  immediately.
- /proxy/cover: applied the same CURLOPT_RESOLVE IP-pinning as
  /api/plugins/proxy-image so the cover proxy isn't vulnerable to the DNS-
  rebinding second-lookup (a whitelisted-but-compromised host resolving to an
  internal IP).
- PasswordController: the EmailService-fallback catch used error_log(); the
  PHPMailer message can embed the recipient address, so use SecureLogger::error
  for centralised redaction.
- contacts-tab.php: replaced HtmlHelper::e() with htmlspecialchars(ENT_QUOTES)
  on the reCAPTCHA secret value, per the project's no-HtmlHelper-in-views rule.

phpstan level 5 full-tree clean; unit battery 68/68.
extra-features Test 6 (profile phone edit) and Test 9 (admin creates user)
predate #255, which made address required-by-config on the user forms. On an
install where address is required they left it empty, so the form never
submitted — Test 6 read a NULL phone and Test 9 stayed on /create. Fill address
(guarded on field presence) so both specs pass regardless of the install's
required-field config. Test-only; the product itself persists phone+address
correctly when all required fields are filled.

(Re-applied cleanly on the current specs; the old test/user-form-address-
required-255 branch was based on a stale main and carried vendor/ noise, so it
was not merged.)
@fabiodalez-dev
fabiodalez-dev merged commit 0c456a8 into main Jul 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant