Skip to content

security: fix stored XSS in per-copy fields + harden two open redirects#267

Open
fabiodalez-dev wants to merge 1 commit into
mainfrom
security/xss-review-fixes
Open

security: fix stored XSS in per-copy fields + harden two open redirects#267
fabiodalez-dev wants to merge 1 commit into
mainfrom
security/xss-review-fixes

Conversation

@fabiodalez-dev

Copy link
Copy Markdown
Owner

Full-app XSS review (server views · client JS/DOM · controllers/headers). One real stored XSS, two low-severity open redirects, and three defence-in-depth encoding gaps. The rest of the output surface was verified safe.

🔴 High — Stored XSS in per-copy fields (app/Views/libri/scheda_libro.php)

The staff-writable per-copy fields numero_inventario / note / stato (copy tracking #238) were rendered into inline onclick handlers with only htmlspecialchars(ENT_QUOTES). That is the wrong encoding for a JS string inside an HTML attribute: the browser HTML-decodes the attribute ('') before the JS parser runs, so a copy code like ');alert(document.domain)// broke out of the string and executed in another admin's browser when they clicked the copy edit/delete button — a staff→admin persistence/escalation vector (issue #27). numero_inventario was additionally dropped raw into a SweetAlert html: template.

Fix: json_encode() the values with JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP for the two onclick handlers, and escapeHtml() the SweetAlert body. The same value was already escaped correctly elsewhere in the file — this was a localized oversight, not a systemic gap.

🟡 Low — Open redirects (no header injection — PSR-7 blocks CRLF)

  • AutoriController::delete used str_contains($referer, '/admin/authors'), which accepts https://evil.tld/admin/authors. Now validates same-host + path before bouncing back, else falls back to url('/admin/authors').
  • public/index.php force-HTTPS redirect reflected the raw Host header (and exited before the canonical-host enforcement). Now prefers the APP_CANONICAL_URL host when configured.

🔵 Defence in depth (low, not currently reachable)

  • Home JSON-LD (FrontendController) and the notification "Apri" label (layout.php) now include JSON_HEX_TAG.
  • The bare route_path() in the latest-books CTA href is now htmlspecialchars()-wrapped (convention).

Tests

  • tests/xss-copy-fields-encoding.unit.php — 30 assertions: the exact view encoding renders inert for every breakout payload (quote/tag/attr/backslash/unicode).
  • tests/xss-copy-fields.spec.js — E2E: seeds a copy carrying a live payload into the DB, opens the real book-detail page, clicks delete (SweetAlert) and edit — no onclick breakout, no injected <img>, no dialog; structural check confirms the json-encoded handler.

Verification: PHPStan level 5 full-tree clean, unit 30/30, E2E 1/1.

Verified safe (broad coverage)

Header injection structurally impossible (PSR-7 rejects \r\n); RSS/Atom/sitemap/ICS/OAI all escaped; JSON always application/json; Content-Disposition filenames sanitized; file serving realpath-contained + nosniff. Views: all other onX=/JSON-LD/script contexts use (int) or json_encode(JSON_HEX_TAG)+htmlspecialchars; rich text sanitized at save via HtmlHelper::sanitizeHtml(); meta/OG tags htmlspecialchars. Client JS: unified search, DataTables, autocomplete, file preview, other SweetAlert html: all escape via escapeHtml/textContent/createElement.

Full-app XSS review. One real stored XSS plus two low-severity open
redirects and three defence-in-depth encoding gaps.

Stored XSS (high) — app/Views/libri/scheda_libro.php. The staff-writable
per-copy fields numero_inventario/note/stato (copy tracking #238) were
emitted into inline onclick handlers with only htmlspecialchars(ENT_QUOTES)
— the wrong encoding for a JS string inside an HTML attribute: the browser
HTML-decodes the attribute before the JS parser runs, so a copy code like
');alert(document.domain)// broke out of the string and executed in another
admin's browser on click of the copy edit/delete button (a staff->admin
vector, issue #27). numero_inventario was additionally dropped raw into a
SweetAlert html: template. Fix: json_encode() the values (JSON_HEX_* flags)
for the onclick handlers and escapeHtml() the SweetAlert body.

Open redirects (low):
- AutoriController::delete used str_contains(referer, '/admin/authors'),
  which accepts https://evil.tld/admin/authors. Now validates same-host and
  path before bouncing back, else falls back to url('/admin/authors').
- public/index.php force-HTTPS redirect reflected the raw Host header.
  Now prefers the APP_CANONICAL_URL host when configured.

Defence in depth (low, not currently reachable): add JSON_HEX_TAG to the
home JSON-LD (FrontendController) and to the notification 'Apri' label
(layout.php); escape the bare route_path() href in the latest-books CTA.

Tests: xss-copy-fields-encoding.unit.php (30 assertions — the exact view
encoding is inert for every breakout payload) and xss-copy-fields.spec.js
(seeds a copy with a live payload, drives the real book-detail page: no
onclick breakout, no SweetAlert <img> injection, no dialog).
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 6 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

Run ID: d221e150-1cc2-4cc6-9714-ae0b35d3d939

📥 Commits

Reviewing files that changed from the base of the PR and between 221f018 and 9440e1e.

📒 Files selected for processing (8)
  • app/Controllers/AutoriController.php
  • app/Controllers/FrontendController.php
  • app/Views/frontend/home-sections/latest_books_title.php
  • app/Views/layout.php
  • app/Views/libri/scheda_libro.php
  • public/index.php
  • tests/xss-copy-fields-encoding.unit.php
  • tests/xss-copy-fields.spec.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/xss-review-fixes

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.

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