Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app/Controllers/AutoriController.php
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,19 @@ public function delete(Request $request, Response $response, mysqli $db, int $id
session_start();
}
$_SESSION['error_message'] = __('Impossibile eliminare l\'autore: sono presenti libri associati.');
// Only bounce back to the referring authors page when it is a
// same-host /admin/authors URL — a bare substring check would let
// https://evil.tld/admin/authors through as an open redirect.
$referer = $request->getHeaderLine('Referer');
$target = str_contains($referer, '/admin/authors') ? $referer : '/admin/authors';
$target = url('/admin/authors');
if ($referer !== '' && strpbrk($referer, "\r\n") === false && !str_starts_with($referer, '//')) {
$parsed = parse_url($referer);
$host = $parsed['host'] ?? null;
$sameHost = $host === null || $host === ($_SERVER['HTTP_HOST'] ?? '');
if ($sameHost && str_contains((string) ($parsed['path'] ?? ''), '/admin/authors')) {
$target = $referer;
}
}
return $response->withHeader('Location', $target)->withStatus(302);
}

Expand Down
7 changes: 4 additions & 3 deletions app/Controllers/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,10 +377,11 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe
$orgSchema['sameAs'] = $sameAs;
}

// Combine schemas
$seoSchema = json_encode([$schemaOrg, $orgSchema], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
// Combine schemas. JSON_HEX_TAG neutralises any </script> that could
// reach the value (defence in depth on top of the strip_tags at save).
$seoSchema = json_encode([$schemaOrg, $orgSchema], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG);
} else {
$seoSchema = json_encode($schemaOrg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$seoSchema = json_encode($schemaOrg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG);
}

// Render template
Expand Down
2 changes: 1 addition & 1 deletion app/Views/frontend/home-sections/latest_books_title.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<i class="fas fa-plus"></i>
<?= __("Carica Altri") ?>
</button>
<a href="<?= $legacyCatalogRoute ?>" class="btn-cta">
<a href="<?= htmlspecialchars($legacyCatalogRoute, ENT_QUOTES, 'UTF-8') ?>" class="btn-cta">
<i class="fas fa-th-large"></i>
<?= __("Visualizza Tutto il Catalogo") ?>
</a>
Expand Down
2 changes: 1 addition & 1 deletion app/Views/layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ function initializeDropdowns() {
</p>
<p class="text-xs text-gray-600 mt-1 group-hover:text-gray-700 transition-colors">${escapeHtml(notif.message || '')}</p>
${hasLink ? `
<?php $openLabel = json_encode(__('Apri'), JSON_UNESCAPED_UNICODE); ?>
<?php $openLabel = json_encode(__('Apri'), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>
<div class="mt-3">
<button type="button" class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-white bg-gray-900 rounded-lg shadow-sm hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500/40" data-open-link="${escapedLink}">
<i class="fas fa-external-link-alt text-[11px]"></i>
Expand Down
6 changes: 3 additions & 3 deletions app/Views/libri/scheda_libro.php
Original file line number Diff line number Diff line change
Expand Up @@ -1062,15 +1062,15 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm
?>
<?php if ($canEdit): ?>
<button type="button"
onclick="openEditCopyModal(<?php echo (int)$copia['id']; ?>, '<?php echo htmlspecialchars($copia['stato'] ?? '', ENT_QUOTES); ?>', '<?php echo htmlspecialchars($copia['note'] ?? '', ENT_QUOTES); ?>')"
onclick="openEditCopyModal(<?php echo (int)$copia['id']; ?>, <?php echo htmlspecialchars((string) json_encode($copia['stato'] ?? '', JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8'); ?>, <?php echo htmlspecialchars((string) json_encode($copia['note'] ?? '', JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8'); ?>)"
class="text-blue-600 hover:text-blue-900 transition-colors"
title="<?= __("Modifica stato") ?>">
<i class="fas fa-edit"></i>
</button>
<?php endif; ?>
<?php if ($canDelete): ?>
<button type="button"
onclick="confirmDeleteCopy(<?php echo (int)$copia['id']; ?>, '<?php echo htmlspecialchars($copia['numero_inventario'], ENT_QUOTES); ?>')"
onclick="confirmDeleteCopy(<?php echo (int)$copia['id']; ?>, <?php echo htmlspecialchars((string) json_encode($copia['numero_inventario'] ?? '', JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8'); ?>)"
class="text-red-600 hover:text-red-900 transition-colors"
title="<?= __("Elimina copia") ?>">
<i class="fas fa-trash"></i>
Expand Down Expand Up @@ -1978,7 +1978,7 @@ function confirmDeleteCopy(copyId, numeroInventario) {
if (window.Swal) {
Swal.fire({
title: __('Elimina copia'),
html: `${__('Sei sicuro di voler eliminare la copia')} <strong>${numeroInventario}</strong>?<br><span class="text-sm text-gray-600">${__('Questa azione non può essere annullata.')}</span>`,
html: `${__('Sei sicuro di voler eliminare la copia')} <strong>${escapeHtml(numeroInventario)}</strong>?<br><span class="text-sm text-gray-600">${__('Questa azione non può essere annullata.')}</span>`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: __('Sì, elimina'),
Expand Down
11 changes: 11 additions & 0 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,18 @@
$forceHttps = $forceHttpsFromDb || (getenv('APP_ENV') === 'production' && $forceHttpsFromEnv);

if ($forceHttps) {
// Prefer the configured canonical host over the raw Host header so an
// attacker-supplied Host on a catch-all vhost cannot turn the HTTPS
// upgrade into an open redirect (https://evil.tld/…).
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$canonicalUrl = getenv('APP_CANONICAL_URL') ?: ($_ENV['APP_CANONICAL_URL'] ?? '');
if ($canonicalUrl !== '') {
$canonicalParts = parse_url($canonicalUrl);
if (!empty($canonicalParts['host'])) {
$host = $canonicalParts['host']
. (isset($canonicalParts['port']) ? ':' . (int) $canonicalParts['port'] : '');
}
}
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
header('Location: https://' . $host . $requestUri, true, 301);
exit;
Expand Down
85 changes: 85 additions & 0 deletions tests/xss-copy-fields-encoding.unit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);

/**
* Security guard for the per-copy fields rendered into inline onclick handlers
* on the admin book-detail page (app/Views/libri/scheda_libro.php).
*
* numero_inventario / stato / note are staff-writable free text (copy-tracking
* #238). They were emitted into `onclick="fn(id, '<value>')"` with only
* htmlspecialchars(ENT_QUOTES). That is the WRONG encoding for a JS string
* inside an HTML attribute: the browser HTML-decodes the attribute (`&#039;`→`'`)
* BEFORE the JS parser runs, so a value like `');alert(1)//` breaks out of the
* string and executes — a stored XSS that fires in another admin's browser when
* they click the copy edit/delete button.
*
* The fix wraps the value in json_encode() with the JSON_HEX_* flags (so the JS
* string cannot be broken) and then htmlspecialchars() (so the JSON's delimiting
* quotes cannot terminate the HTML attribute). This test reproduces that exact
* expression and asserts the rendered handler is inert for every payload.
*
* Run: php tests/xss-copy-fields-encoding.unit.php (exit 0 iff all pass)
*/

$pass = 0;
$fail = 0;
$check = static function (bool $ok, string $label) use (&$pass, &$fail): void {
if ($ok) { $pass++; echo " OK {$label}\n"; }
else { $fail++; echo " FAIL {$label}\n"; }
};

/** The exact PHP expression scheda_libro.php now uses for a copy field. */
$encode = static fn (string $value): string => htmlspecialchars(
(string) json_encode($value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE),
ENT_QUOTES,
'UTF-8'
);

/**
* Emulate a browser: the HTML attribute value is HTML-decoded, then the JS
* argument is parsed. We assert the decoded-then-evaluated argument equals the
* original string exactly (no breakout), and that neither the raw nor the
* HTML-decoded attribute can terminate the attribute or the JS string early.
*/
echo "A. onclick JS-string context — no breakout\n";
$payloads = [
'string-break-alert' => "');alert(document.domain)//",
'double-quote-break' => '");alert(1)//',
'tag-inject' => '</script><script>alert(1)</script>',
'img-onerror' => '<img src=x onerror=alert(1)>',
'attr-break' => '"><svg onload=alert(1)>',
'backslash' => "\\');alert(1)//",
'newline' => "a\n');alert(1)//",
'unicode-quote' => "\u{2028}');alert(1)//",
'plain-code' => 'INV-001',
'quotes-mix' => 'O\'Brien "the" <b>bold</b>',
];

foreach ($payloads as $name => $raw) {
$attr = $encode($raw);

// 1. The rendered attribute value must not contain a raw " that would end
// the double-quoted onclick="…" attribute early.
$check(!str_contains($attr, '"'), "payload '{$name}': no raw double-quote in attribute");

// 2. HTML-decode the attribute the way a browser does before running the JS.
$decoded = html_entity_decode($attr, ENT_QUOTES, 'UTF-8');

// 3. The decoded text is a JS expression argument: `"<escaped>"`. It must be
// a single well-formed JS string literal — i.e. exactly one opening and
// one closing unescaped double quote, with nothing after it. json_decode
// parses JS/JSON string escapes identically, so it recovers the original.
$recovered = json_decode($decoded, true);
$check($recovered === $raw,
"payload '{$name}': JS string literal round-trips to the original (no breakout)");

// 4. Belt-and-suspenders: the decoded literal has no unescaped `'`/`"`/`<`
// that could break the string or the surrounding markup, apart from the
// two delimiting quotes.
$inner = substr($decoded, 1, -1); // strip the delimiting quotes
$check(!preg_match('/(?<!\\\\)["\']/', $inner) && !str_contains($inner, '<') && !str_contains($inner, '>'),
"payload '{$name}': inner JS string has no live quote/angle-bracket");
}

echo "\n" . ($fail === 0 ? "ALL {$pass} PASS\n" : "{$pass} PASS, {$fail} FAIL\n");
exit($fail === 0 ? 0 : 1);
132 changes: 132 additions & 0 deletions tests/xss-copy-fields.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @ts-check
//
// End-to-end proof that staff-writable per-copy fields (numero_inventario, note)
// cannot execute script on the admin book-detail page. These fields (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 — and numero_inventario was also dropped raw into a SweetAlert
// `html:` template. A copy code like `');window.x=1//<img src=x onerror=…>` then
// ran in another admin's browser on click of the copy edit/delete button.
//
// The fix json_encode()s the values for the onclick handlers and escapeHtml()s
// the SweetAlert body. This spec seeds a copy carrying a live payload straight
// into the DB, opens the REAL book-detail page, clicks the buttons, and asserts
// nothing executes — while the pure-PHP encoding is covered by
// tests/xss-copy-fields-encoding.unit.php.
//
// Run: /tmp/run-e2e.sh tests/xss-copy-fields.spec.js \
// --config=tests/playwright.config.js --workers=1

const { test, expect } = require('@playwright/test');
const { execFileSync } = require('child_process');

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || '';
const ADMIN_PASS = process.env.E2E_ADMIN_PASS || '';
const DB_USER = process.env.E2E_DB_USER || '';
const DB_PASS = process.env.E2E_DB_PASS || '';
const DB_NAME = process.env.E2E_DB_NAME || '';
const DB_SOCKET = process.env.E2E_DB_SOCKET || '';
const DB_HOST = process.env.E2E_DB_HOST || '';
const DB_PORT = process.env.E2E_DB_PORT || '';

test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, 'Missing E2E env (admin/DB)');

function mysqlArgs(sql) {
const args = [];
if (DB_HOST) { args.push('-h', DB_HOST); if (DB_PORT) args.push('-P', DB_PORT); }
else if (DB_SOCKET) { args.push('-S', DB_SOCKET); }
args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql);
return args;
}
function dbQuery(sql) {
return execFileSync('mysql', mysqlArgs(sql), {
encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS },
}).trim();
}
const sqlStr = (s) => "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";

async function loginAsAdmin(page) {
await page.goto(`${BASE}/admin/dashboard`);
const emailField = page.locator('input[name="email"]');
if (await emailField.isVisible({ timeout: 3000 }).catch(() => false)) {
await emailField.fill(ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASS);
await page.click('button[type="submit"]');
await page.waitForURL(/.*(?:dashboard|admin).*/);
}
}

test.describe('Per-copy field XSS hardening', () => {
const uniq = Date.now();
// numero_inventario feeds BOTH the confirmDeleteCopy onclick AND the SweetAlert
// html; note feeds the openEditCopyModal onclick. Each payload flips a distinct
// window flag iff it executes.
const invPayload = `');window.__xssInvJs=true;//<img src=x onerror="window.__xssInvHtml=true">_${uniq}`;
const notePayload = `');window.__xssNoteJs=true;//_${uniq}`;
let bookId = 0;
let copyId = 0;

test.beforeAll(() => {
bookId = parseInt(dbQuery('SELECT id FROM libri WHERE deleted_at IS NULL ORDER BY id LIMIT 1'), 10);
if (!bookId) return;
// 'danneggiato' → the book-detail view shows BOTH the edit and delete buttons.
dbQuery(
`INSERT INTO copie (libro_id, numero_inventario, stato, note) VALUES ` +
`(${bookId}, ${sqlStr(invPayload)}, 'danneggiato', ${sqlStr(notePayload)})`
);
copyId = parseInt(dbQuery(`SELECT id FROM copie WHERE numero_inventario = ${sqlStr(invPayload)}`), 10);
});

test.afterAll(() => {
if (copyId) dbQuery(`DELETE FROM copie WHERE id = ${copyId}`);
});

test('malicious copy code/note never execute on the book-detail page', async ({ page }) => {
test.skip(!bookId || !copyId, 'could not seed a copy (no book in DB)');

let dialogFired = false;
page.on('dialog', (d) => { dialogFired = true; d.dismiss().catch(() => {}); });

await loginAsAdmin(page);
await page.goto(`${BASE}/admin/books/${bookId}`);
await page.waitForLoadState('domcontentloaded');

const delBtn = page.locator(`button[onclick^="confirmDeleteCopy(${copyId},"]`);
const editBtn = page.locator(`button[onclick^="openEditCopyModal(${copyId},"]`);
await expect(delBtn).toHaveCount(1);

// Structural: the fix emits a json-encoded string (double-quote delimited),
// never the old single-quoted `confirmDeleteCopy(id, '…')` breakout form.
const delOnclick = await delBtn.getAttribute('onclick');
expect(delOnclick).toMatch(new RegExp(`^confirmDeleteCopy\\(${copyId},\\s*"`));
const editOnclick = await editBtn.getAttribute('onclick');
expect(editOnclick).toMatch(new RegExp(`^openEditCopyModal\\(${copyId},`));

// Behavioural — DELETE path: onclick must not break out, and the SweetAlert
// body must escape the code (no injected <img> onerror).
await delBtn.click();
await page.locator('.swal2-popup').waitFor({ state: 'visible', timeout: 8000 });
// The payload must appear as inert TEXT, not as a live <img> element.
const liveImg = await page.evaluate(() =>
document.querySelectorAll('.swal2-html-container img[src="x"]').length);
expect(liveImg, 'the SweetAlert body must not contain a live injected <img>').toBe(0);
await page.locator('.swal2-cancel').click();
await page.locator('.swal2-popup').waitFor({ state: 'hidden', timeout: 5000 });

// Behavioural — EDIT path: onclick must not break out.
await editBtn.click();
await page.waitForTimeout(300); // let the modal + any (non-)injected code settle

// Nothing the payloads would have set may exist, and no dialog may have fired.
const flags = await page.evaluate(() => ({
invJs: /** @type {any} */ (window).__xssInvJs ?? null,
invHtml: /** @type {any} */ (window).__xssInvHtml ?? null,
noteJs: /** @type {any} */ (window).__xssNoteJs ?? null,
}));
expect(flags.invJs, 'confirmDeleteCopy onclick must not break out').toBeNull();
expect(flags.invHtml, 'SweetAlert html must escape the copy code').toBeNull();
expect(flags.noteJs, 'openEditCopyModal onclick must not break out').toBeNull();
expect(dialogFired, 'no dialog may be raised by the payloads').toBe(false);
});
});
Loading