mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 11:06:39 +00:00
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:
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
_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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function isStarlarkInstalled(appId) {
|
||||
|
||||
Reference in New Issue
Block a user