Compare commits

...

2 Commits

Author SHA1 Message Date
jared b51b39c3a7 a11y: keyboard-accessible panel toggles, region landmarks in inspector
Lint / Python (flake8) (push) Successful in 43s
Lint / JS (eslint) (push) Successful in 14s
Security / Python Security (bandit) (push) Successful in 45s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Add role="button" tabindex="0" aria-expanded to .link-host-title
  in both static and JS-rendered panels (host panels + UniFi switches)
- Sync aria-expanded in togglePanel(), restoreCollapseState(),
  collapseAll(), and expandAll()
- Add keydown handler (Enter/Space) so panel headers are keyboard-operable
- Add role="region" aria-label to inspector main chassis area
- Add role="complementary" aria-label to inspector port detail panel
- Replace last inline date-parse in renderLinks() with _toIso() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:44:23 -04:00
jared 41695a3faa security: escape user input in 403 error response to prevent XSS
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
The require_auth decorator was interpolating user['username'] and the
allowed_groups list directly into HTML strings. An attacker with a
crafted username or control over group names could inject arbitrary HTML.

Use html.escape() on both values before insertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:41:31 -04:00
3 changed files with 33 additions and 15 deletions
+5 -2
View File
@@ -5,6 +5,7 @@ management UI. Authentication via Authelia forward-auth headers.
All monitoring and alerting is handled by the separate monitor.py daemon. All monitoring and alerting is handled by the separate monitor.py daemon.
""" """
import hashlib import hashlib
import html
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -132,10 +133,12 @@ def require_auth(f):
) )
allowed = _config().get('auth', {}).get('allowed_groups', ['admin']) allowed = _config().get('auth', {}).get('allowed_groups', ['admin'])
if not any(g in allowed for g in user['groups']): if not any(g in allowed for g in user['groups']):
safe_user = html.escape(user['username'])
safe_groups = html.escape(', '.join(allowed))
return ( return (
f'<h1>403 Access denied</h1>' f'<h1>403 Access denied</h1>'
f'<p>Your account ({user["username"]}) is not in an allowed group ' f'<p>Your account ({safe_user}) is not in an allowed group '
f'({", ".join(allowed)}).</p>', f'({safe_groups}).</p>',
403, 403,
) )
return f(*args, **kwargs) return f(*args, **kwargs)
+2 -2
View File
@@ -14,10 +14,10 @@
</div> </div>
<div class="inspector-layout"> <div class="inspector-layout">
<div class="inspector-main" id="inspector-main"> <div class="inspector-main" id="inspector-main" role="region" aria-label="Switch chassis diagrams">
<div class="link-loading">Loading inspector data</div> <div class="link-loading">Loading inspector data</div>
</div> </div>
<div class="inspector-panel" id="inspector-panel"> <div class="inspector-panel" id="inspector-panel" role="complementary" aria-label="Port detail panel">
<div class="inspector-panel-inner" id="inspector-panel-inner"></div> <div class="inspector-panel-inner" id="inspector-panel-inner"></div>
</div> </div>
</div> </div>
+26 -11
View File
@@ -349,7 +349,7 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
return ` return `
<div class="link-host-panel" id="panel-${CSS.escape(swName)}"> <div class="link-host-panel" id="panel-${CSS.escape(swName)}">
<div class="link-host-title" data-action="toggle-panel"> <div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
<span class="link-host-name">${escHtml(swName)}</span> <span class="link-host-name">${escHtml(swName)}</span>
<span class="link-host-ip">${escHtml(sw.ip || '')}</span> <span class="link-host-ip">${escHtml(sw.ip || '')}</span>
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span> <span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
@@ -366,8 +366,11 @@ function renderUnifiSwitches(unifiSwitches, dataUpdated) {
// ── Panel collapse / expand ─────────────────────────────────────── // ── Panel collapse / expand ───────────────────────────────────────
function togglePanel(panel) { function togglePanel(panel) {
panel.classList.toggle('collapsed'); panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle'); const isCollapsed = panel.classList.contains('collapsed');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]'; const btn = panel.querySelector('.panel-toggle');
const title = panel.querySelector('.link-host-title');
if (btn) btn.textContent = isCollapsed ? '[+]' : '[]';
if (title) title.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
const id = panel.id; const id = panel.id;
if (id) { if (id) {
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}'); const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
@@ -383,8 +386,10 @@ function restoreCollapseState() {
if (!panel) continue; if (!panel) continue;
if (isCollapsed) { if (isCollapsed) {
panel.classList.add('collapsed'); panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle'); const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; const title = panel.querySelector('.link-host-title');
if (btn) btn.textContent = '[+]';
if (title) title.setAttribute('aria-expanded', 'false');
} }
} }
} }
@@ -463,12 +468,12 @@ function renderLinks(data) {
const sample = Object.values(ifaces)[0] || {}; const sample = Object.values(ifaces)[0] || {};
const ip = sample.host_ip || ''; const ip = sample.host_ip || '';
const updStr = data.updated const updStr = data.updated
? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString() ? new Date(_toIso(data.updated)).toLocaleTimeString()
: ''; : '';
parts.push(` parts.push(`
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}"> <div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
<div class="link-host-title" data-action="toggle-panel"> <div class="link-host-title" data-action="toggle-panel" role="button" tabindex="0" aria-expanded="true">
<span class="link-host-name">${escHtml(hostname)}</span> <span class="link-host-name">${escHtml(hostname)}</span>
<span class="link-host-ip">${escHtml(ip)}</span> <span class="link-host-ip">${escHtml(ip)}</span>
<span class="link-host-upd">${updStr}</span> <span class="link-host-upd">${updStr}</span>
@@ -498,8 +503,10 @@ function applyLinksSearch() {
function collapseAll() { function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(p => { document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.add('collapsed'); p.classList.add('collapsed');
const btn = p.querySelector('.panel-toggle'); const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]'; const title = p.querySelector('.link-host-title');
if (btn) btn.textContent = '[+]';
if (title) title.setAttribute('aria-expanded', 'false');
}); });
sessionStorage.setItem('linksCollapsed', JSON.stringify( sessionStorage.setItem('linksCollapsed', JSON.stringify(
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true])) Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
@@ -509,8 +516,10 @@ function collapseAll() {
function expandAll() { function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(p => { document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.remove('collapsed'); p.classList.remove('collapsed');
const btn = p.querySelector('.panel-toggle'); const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]'; const title = p.querySelector('.link-host-title');
if (btn) btn.textContent = '[]';
if (title) title.setAttribute('aria-expanded', 'true');
}); });
sessionStorage.setItem('linksCollapsed', '{}'); sessionStorage.setItem('linksCollapsed', '{}');
} }
@@ -575,6 +584,12 @@ document.addEventListener('click', e => {
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; } if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
}); });
document.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
if (toggleTitle) { e.preventDefault(); togglePanel(toggleTitle.closest('.link-host-panel')); }
});
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch); document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
</script> </script>
{% endblock %} {% endblock %}