merge: bring the element-style work onto main's api_v3 package split

main moved 16 commits while this branch was open, and one of them split
api_v3.py into a package. A rebase would have conflicted on that file in
each of the 15 commits that touch it; merging resolves it once.

Reapplied into the package, unchanged in substance:

- _schema_type_is / _schema_allows_null and the nullable-field handling
  (api_v3/__init__.py, where the form parser now lives)
- the indexed-array recombiner's union-type fix and its all-blank-means-null
  case (api_v3/plugins.py)
- scalable / native_size on the font catalog, and the removal of the three
  font-override stubs (api_v3/fonts.py)

Two places took main's version rather than mine, deliberately:

- pages_v3 now reads the schema through SchemaManager *and* keeps
  resolve_under for the raw-read fallback, so the containment guard main
  added in the CodeQL triage survives.
- serve_plugin_widget moves off os.path.basename onto safe_path_component
  and resolve_under. main dropped `import os` from pages_v3 in favour of
  those helpers, and they reject a bad component rather than mangling it
  into a valid-looking one.

The url-map snapshot loses exactly the three font-override routes. Edited
rather than regenerated: that test exists to catch a route lost by accident
during a move, and regenerating it wholesale is how such a loss would be
papered over.

Verified against a fresh run of current main: same failure set, 4692
passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 19:27:06 -04:00
co-authored by Claude Opus 5
101 changed files with 16851 additions and 10432 deletions
+9 -4
View File
@@ -602,9 +602,9 @@
};
// Build the <i class="..."> + label as DOM nodes so a
// hostile plugin.icon (e.g. containing a quote) can't
// break out of the attribute. escapeHtml only escapes
// <, >, &, not ", so attribute-context interpolation
// would be unsafe.
// break out of the attribute. escapeHtml now escapes
// quotes too, but setting the property directly cannot
// be got wrong at all, so it stays.
const iconEl = document.createElement('i');
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
const labelNode = document.createTextNode(plugin.name || plugin.id);
@@ -643,10 +643,15 @@
}
},
// Quotes too, so the result is safe inside a quoted attribute
// value -- the textContent/innerHTML round-trip alone only
// escapes &, < and >.
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
async refreshPlugins() {
@@ -260,6 +260,9 @@ function closeErrorModal() {
/**
* Escape HTML to prevent XSS.
*
* Quotes are escaped as well so the result is safe inside a quoted attribute
* value -- the textContent/innerHTML round-trip alone only escapes &, < and >.
*/
function escapeHtml(text) {
if (typeof text !== 'string') {
@@ -267,7 +270,9 @@ function escapeHtml(text) {
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
@@ -118,15 +118,27 @@
/**
* Escape HTML to prevent XSS
* Always escapes the input, even for non-strings, by coercing to string first
*
* The result is safe in text content AND inside quoted attribute values.
* The textContent/innerHTML round-trip only escapes `&`, `<` and `>` --
* the HTML serializer leaves quotes alone because they are harmless in a
* text node. Every widget here interpolates the result into attributes
* (`value="${escapeHtml(v)}"`), where an unescaped `"` closes the
* attribute and lets the value inject its own, so the quotes have to go
* too. Each widget's standalone fallback already did this; the shared
* implementation they all prefer did not.
*
* @param {*} text - Text to escape (will be coerced to string)
* @returns {string} Escaped text
* @returns {string} Escaped text, safe for text and attribute contexts
*/
escapeHtml(text) {
// Always coerce to string first, then escape
const textStr = String(text);
const div = document.createElement('div');
div.textContent = textStr;
return div.innerHTML;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
@@ -50,10 +50,14 @@
}
// Escape HTML to prevent XSS (for HTML contexts)
// Quotes too: the result lands in quoted attribute values below, and
// the textContent/innerHTML round-trip only escapes &, < and >.
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
div.textContent = String(text ?? '');
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// Use validated/sanitized hex for style attribute and input values
@@ -804,10 +804,14 @@
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
// Escape HTML helper
// Quotes too: the result lands in quoted attribute values below, and the
// textContent/innerHTML round-trip only escapes &, < and >.
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
div.textContent = String(text ?? '');
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// Use sanitizedId for all ID references in the schedule HTML
@@ -738,10 +738,15 @@
delete btn._jfmOrigText;
}
// Quotes too: the result lands in quoted attribute values (title=,
// data-cat=, pattern=, ...), and the textContent/innerHTML round-trip
// only escapes &, < and >.
_esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
_fmtSize(bytes) {
@@ -206,10 +206,15 @@
else console.log(`[PFM][${type}] ${msg}`);
}
// Quotes too: the result lands in quoted attribute values (id=, value=,
// data-col=), and the textContent/innerHTML round-trip only escapes
// &, < and >.
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatSize(bytes) {
@@ -383,7 +388,7 @@
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1"></i>Save
</button>
</div>`;
</div>`);
overlay.appendChild(modal);
// Bind events after DOM insertion — filename captured in closure, not in HTML.
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
@@ -411,7 +416,7 @@
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
}
};
@@ -431,6 +436,20 @@
if (!entries.length) { container.textContent = 'No entries.'; return; }
const cols = Object.keys(entries[0][1]);
// Delegated listener: day/col reach _pfmCellEdit only through data-*
// attributes, never through a JS string spliced into an inline
// handler -- the browser HTML-decodes attribute values before
// running them as script, which undoes escHtml's quote escaping and
// reopens the exact injection it exists to close. `container` is a
// fresh element per modal open (see _pfmOpenEdit), so this attaches
// exactly once per table, even though buildPage() re-renders below.
container.addEventListener('input', (e) => {
const cell = e.target.closest('input[data-day], textarea[data-day]');
if (!cell) return;
window._pfmCellEdit(fieldId, cell.dataset.day, cell.dataset.col, cell.value);
});
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
const total = entries.length;
@@ -459,18 +478,16 @@
</thead>
<tbody>
${pageEntries.map(([day, val]) => `
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
<tr data-day="${escHtml(day)}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
${cols.map(col => {
const v = val[col] ?? '';
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
return isLong
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
? `<td><textarea data-day="${escHtml(day)}" data-col="${escHtml(col)}" rows="2"
>${escHtml(String(v))}</textarea></td>`
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
value="${escHtml(String(v))}"
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
: `<td><input type="text" data-day="${escHtml(day)}" data-col="${escHtml(col)}"
value="${escHtml(String(v))}"></td>`;
}).join('')}
</tr>`).join('')}
</tbody>
@@ -489,7 +506,7 @@
${page >= totalPages ? 'disabled' : ''}
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
</div>
</div>`;
</div>`);
st._tablePage = page;
st._tableEntries = entries;
st._tableCols = cols;
@@ -578,7 +595,7 @@
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1"></i>Delete
</button>
</div>`;
</div>`);
overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
@@ -631,7 +648,7 @@
<i class="fas fa-plus mr-1"></i>Create
</button>
</div>
</div>`;
</div>`);
overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
@@ -784,7 +801,7 @@
<div class="pfm-grid">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
</div>
</div>`;
</div>`);
loadFiles(fieldId);
},
@@ -54,9 +54,16 @@
// RFC 3986 scheme pattern: starts with letter, then letters/digits/+/./-
const RFC_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*$/;
// Schemes that execute script when navigated to. These can never be
// allowed, whatever a schema's allowedProtocols asks for: the validated
// value is written straight into an <a href>, so allowing "javascript"
// here would turn a config field into script execution.
const SCRIPTABLE_SCHEMES = ['javascript', 'data', 'vbscript', 'blob', 'filesystem'];
/**
* Normalize and validate protocol list against RFC 3986 scheme pattern.
* Accepts schemes like "http", "https", "git+ssh", "android-app", etc.
* Scriptable schemes are dropped -- see SCRIPTABLE_SCHEMES.
* @param {Array|string} protocols - Protocol list (array or comma-separated string)
* @returns {Array} Normalized lowercase protocols, defaults to ['http', 'https']
*/
@@ -70,15 +77,25 @@
const normalized = list
.map(p => String(p).trim())
.filter(p => RFC_SCHEME_PATTERN.test(p))
.map(p => p.toLowerCase());
.map(p => p.toLowerCase())
.filter(p => !SCRIPTABLE_SCHEMES.includes(p));
return normalized.length > 0 ? normalized : ['http', 'https'];
}
/**
* True when `string` parses as a URL whose scheme is allowed AND is not
* one that executes script. The scriptable check is repeated here rather
* than trusted to normalizeProtocols so that a caller passing its own
* protocol list cannot re-open the hole.
*/
function isValidUrl(string, allowedProtocols) {
try {
const url = new URL(string);
const protocol = url.protocol.replace(':', '').toLowerCase();
if (SCRIPTABLE_SCHEMES.includes(protocol)) {
return false;
}
if (allowedProtocols && allowedProtocols.length > 0) {
const protocol = url.protocol.replace(':', '').toLowerCase();
return allowedProtocols.includes(protocol);
}
return true;
@@ -87,6 +104,15 @@
}
}
/**
* A value safe to use as an <a href>: the URL itself when it validates,
* and '' otherwise. Keeps the "render an href for whatever is stored"
* path from emitting a javascript: URL that was never validated.
*/
function safeHref(value, allowedProtocols) {
return isValidUrl(value, allowedProtocols) ? value : '';
}
window.LEDMatrixWidgets.register('url-input', {
name: 'URL Input Widget',
version: '1.0.0',
@@ -139,7 +165,7 @@
html += `
<div id="${fieldId}_preview" class="mt-2 ${currentValue && isValidUrl(currentValue, allowedProtocols) ? '' : 'hidden'}">
<a id="${fieldId}_preview_link"
href="${escapeHtml(currentValue)}"
href="${escapeHtml(safeHref(currentValue, allowedProtocols))}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
@@ -231,10 +257,23 @@
const protocols = normalizeProtocols(widgetEl?.dataset.protocols);
if (previewEl && previewLink) {
if (value && isValidUrl(value, protocols)) {
// Scheme checked inline, right where the value reaches the DOM sink,
// rather than through safeHref/isValidUrl -- CodeQL's DOM-based-XSS
// sanitizer recognition does not trace a boolean-returning helper two
// calls deep, so it kept flagging this assignment even though the
// scriptable-scheme check (see SCRIPTABLE_SCHEMES) already covered it.
let scheme = '';
try {
scheme = value ? new URL(value).protocol.replace(':', '').toLowerCase() : '';
} catch (_) {
scheme = '';
}
const schemeIsSafe = !!scheme && !SCRIPTABLE_SCHEMES.includes(scheme) && protocols.includes(scheme);
if (schemeIsSafe) {
previewLink.href = value;
previewEl.classList.remove('hidden');
} else {
previewLink.removeAttribute('href');
previewEl.classList.add('hidden');
}
}
+16 -5
View File
@@ -4370,12 +4370,15 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
return;
}
// Escape HTML helper
// Escape HTML helper. Quotes too: the result lands in quoted attribute
// values, and the textContent/innerHTML round-trip only escapes &, < and >.
const escapeHtml = (text) => {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// Helper function to escape for JavaScript strings
@@ -4471,11 +4474,15 @@ function isGithubUrl(url) {
}
}
// Utility function to escape HTML
// Utility function to escape HTML. Quotes too: most call sites interpolate the
// result into a quoted attribute value, and the textContent/innerHTML
// round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Utility function to escape text for use in HTML attributes
@@ -5623,11 +5630,15 @@ document.addEventListener('htmx:afterSettle', function() {
let starlarkDataLoaded = false;
// ── Helpers ─────────────────────────────────────────────────────────────
// Quotes too: the result lands in quoted attribute values (data-app-id=,
// title=), and the textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function isStarlarkInstalled(appId) {