security(web): remove creds from image URLs, auth-fail state, tighten CSP
Minor PR-review follow-ups for the local PWA: - Macro images load via a header-authenticated fetch -> blob object URL in both token and relay modes, so no credential ever sits in an image URL; prior object URLs are revoked on re-render. - Local-mode auth failure now shows a clear "Access token required" state and stops the reconnect/toast loop (one-shot), instead of retrying forever. - CSP connect-src tightened from 'self' ws: wss: to 'self' (same-origin WS is covered); verified the live WebSocket still connects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'none'">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'">
|
||||||
<meta name="theme-color" content="#15161a" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#15161a" media="(prefers-color-scheme: dark)">
|
||||||
<meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#eceef4" media="(prefers-color-scheme: light)">
|
||||||
<meta name="description" content="Remote macro control for your desktop">
|
<meta name="description" content="Remote macro control for your desktop">
|
||||||
|
|||||||
+78
-9
@@ -34,6 +34,13 @@ class MacroPadApp {
|
|||||||
// Guards against out-of-order macro fetches
|
// Guards against out-of-order macro fetches
|
||||||
this._macroReqId = 0;
|
this._macroReqId = 0;
|
||||||
|
|
||||||
|
// Blob object URLs created for macro images (revoked on re-render)
|
||||||
|
this._objectUrls = [];
|
||||||
|
|
||||||
|
// Set once a local-mode auth failure has been surfaced, to avoid
|
||||||
|
// reconnect loops and repeated toasts.
|
||||||
|
this.localAuthFailed = false;
|
||||||
|
|
||||||
// Macro editor state (null = create mode)
|
// Macro editor state (null = create mode)
|
||||||
this.editingMacroId = null;
|
this.editingMacroId = null;
|
||||||
|
|
||||||
@@ -204,12 +211,44 @@ class MacroPadApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleAuthError() {
|
handleAuthError() {
|
||||||
this.showToast('Authentication failed', 'error');
|
|
||||||
if (this.relayMode) {
|
if (this.relayMode) {
|
||||||
|
this.showToast('Authentication failed', 'error');
|
||||||
// Clear stored password and redirect to login
|
// Clear stored password and redirect to login
|
||||||
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
||||||
window.location.href = `/${this.sessionId}`;
|
window.location.href = `/${this.sessionId}`;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local (token) mode: a wrong/missing token makes every request 401 and
|
||||||
|
// the WebSocket close with 1008, which would otherwise reconnect forever
|
||||||
|
// and spam toasts. Surface a single, clear message and stop retrying.
|
||||||
|
if (this.localAuthFailed) return;
|
||||||
|
this.localAuthFailed = true;
|
||||||
|
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.teardownSocket();
|
||||||
|
this.updateConnectionStatus(false, 'Access denied');
|
||||||
|
this.renderAuthRequired();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAuthRequired() {
|
||||||
|
const container = document.getElementById('macro-grid');
|
||||||
|
if (!container) return;
|
||||||
|
container.textContent = '';
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'empty-state';
|
||||||
|
|
||||||
|
const p1 = document.createElement('p');
|
||||||
|
p1.textContent = 'Access token required';
|
||||||
|
|
||||||
|
const p2 = document.createElement('p');
|
||||||
|
p2.className = 'hint';
|
||||||
|
p2.textContent = "Open the link from the desktop app's URL or QR code.";
|
||||||
|
|
||||||
|
wrap.append(p1, p2);
|
||||||
|
container.appendChild(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDesktopDisconnected() {
|
handleDesktopDisconnected() {
|
||||||
@@ -396,15 +435,38 @@ class MacroPadApp {
|
|||||||
|
|
||||||
macroImageUrl(macro) {
|
macroImageUrl(macro) {
|
||||||
if (!macro.image_path) return null;
|
if (!macro.image_path) return null;
|
||||||
const basePath = this.getApiUrl(`/api/image/${macro.image_path}`);
|
// No credential ever goes on the URL; auth is via a header on a
|
||||||
// Credentials go as query params since <img> can't set headers.
|
// fetch() below, and the image is shown as a blob object URL.
|
||||||
|
return this.getApiUrl(`/api/image/${macro.image_path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth-only headers (no Content-Type) for GET fetches like images.
|
||||||
|
getAuthHeaders() {
|
||||||
|
const headers = {};
|
||||||
if (this.relayMode && this.password) {
|
if (this.relayMode && this.password) {
|
||||||
return `${basePath}?password=${encodeURIComponent(this.password)}`;
|
headers['X-MacroPad-Password'] = this.password;
|
||||||
|
} else if (!this.relayMode && this.localToken) {
|
||||||
|
headers['X-MacroPad-Token'] = this.localToken;
|
||||||
}
|
}
|
||||||
if (!this.relayMode && this.localToken) {
|
return headers;
|
||||||
return `${basePath}?token=${encodeURIComponent(this.localToken)}`;
|
}
|
||||||
|
|
||||||
|
// Fetch a macro image with the auth header and display it as a blob
|
||||||
|
// object URL, so no credential is ever placed on an <img> src.
|
||||||
|
async loadMacroImage(url, img, placeholder) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: this.getAuthHeaders() });
|
||||||
|
if (!res.ok) throw new Error('image request failed');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
// Track for best-effort revocation on the next render.
|
||||||
|
this._objectUrls.push(objectUrl);
|
||||||
|
img.src = objectUrl;
|
||||||
|
} catch (e) {
|
||||||
|
// Fall back to the placeholder if the image can't be loaded.
|
||||||
|
img.style.display = 'none';
|
||||||
|
placeholder.style.display = '';
|
||||||
}
|
}
|
||||||
return basePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendering (safe DOM construction only - no user value reaches innerHTML)
|
// Rendering (safe DOM construction only - no user value reaches innerHTML)
|
||||||
@@ -430,6 +492,12 @@ class MacroPadApp {
|
|||||||
const container = document.getElementById('macro-grid');
|
const container = document.getElementById('macro-grid');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
// Best-effort: revoke object URLs from the previous render to avoid leaks.
|
||||||
|
if (this._objectUrls && this._objectUrls.length) {
|
||||||
|
this._objectUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||||
|
}
|
||||||
|
this._objectUrls = [];
|
||||||
|
|
||||||
container.textContent = '';
|
container.textContent = '';
|
||||||
|
|
||||||
const macroEntries = Object.entries(this.macros);
|
const macroEntries = Object.entries(this.macros);
|
||||||
@@ -464,14 +532,15 @@ class MacroPadApp {
|
|||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.className = 'macro-image';
|
img.className = 'macro-image';
|
||||||
img.alt = '';
|
img.alt = '';
|
||||||
img.src = imageSrc;
|
|
||||||
placeholder.style.display = 'none';
|
placeholder.style.display = 'none';
|
||||||
// Fall back to the placeholder if the image fails to load.
|
// Fall back to the placeholder if the (blob) image fails to load.
|
||||||
img.addEventListener('error', () => {
|
img.addEventListener('error', () => {
|
||||||
img.style.display = 'none';
|
img.style.display = 'none';
|
||||||
placeholder.style.display = '';
|
placeholder.style.display = '';
|
||||||
});
|
});
|
||||||
card.appendChild(img);
|
card.appendChild(img);
|
||||||
|
// Header-authenticated fetch -> blob object URL (no credential on src).
|
||||||
|
this.loadMacroImage(imageSrc, img, placeholder);
|
||||||
}
|
}
|
||||||
card.appendChild(placeholder);
|
card.appendChild(placeholder);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user