security(relay): harden internet-facing relay server
Closes the account-takeover chain and related issues found in audit: - WS auth brute-force protection: per-session lockout (authThrottle) + per- socket failure cap that closes the socket (1008); applied to both web-client and desktop auth. Failed auth no longer leaves the socket open for unlimited guesses. - WS upgrades now pass an IP-based rate limiter and Origin allowlist before handleUpgrade (previously bypassed all HTTP middleware); trust proxy set so limiting keys on the real client IP. - /health no longer leaks live session IDs (counts only). - .env.example rate-limit fixed (900000ms / 300) from the accidental ~11k rps. - Credentials moved out of URLs into the X-MacroPad-Password header; images fetched via header + blob URLs; login stores creds in sessionStorage. - Session creation bounded (max sessions, min password length) and TTL-pruned; session store writes are now atomic (temp+rename) and debounced. - Session IDs lengthened to 12 chars with rejection sampling (no modulo bias). - Enable helmet CSP; restrict CORS to configured origins. - Request IDs use crypto.randomUUID; drop postinstall build hook and uuid dep. - Uniform response for unknown session IDs (removes enumeration oracle). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -292,15 +292,12 @@
|
||||
const pathMatch = window.location.pathname.match(/^\/([a-zA-Z0-9]+)/);
|
||||
this.sessionId = pathMatch ? pathMatch[1] : null;
|
||||
|
||||
// Get password from URL or sessionStorage
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.password = urlParams.get('auth') || sessionStorage.getItem(`macropad_${this.sessionId}`);
|
||||
// Get password from storage (never from the URL).
|
||||
this.password = sessionStorage.getItem(`macropad_${this.sessionId}`)
|
||||
|| localStorage.getItem(`macropad_${this.sessionId}`);
|
||||
|
||||
if (this.password) {
|
||||
sessionStorage.setItem(`macropad_${this.sessionId}`, this.password);
|
||||
if (urlParams.has('auth')) {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
this.init();
|
||||
@@ -374,6 +371,7 @@
|
||||
|
||||
handleAuthError() {
|
||||
sessionStorage.removeItem(`macropad_${this.sessionId}`);
|
||||
localStorage.removeItem(`macropad_${this.sessionId}`);
|
||||
window.location.href = `/${this.sessionId}`;
|
||||
}
|
||||
|
||||
@@ -467,19 +465,48 @@
|
||||
}
|
||||
|
||||
container.innerHTML = entries.map(([id, macro]) => {
|
||||
// Include password as query param for image authentication
|
||||
const imageSrc = macro.image_path
|
||||
? `/${this.sessionId}/api/image/${macro.image_path}?password=${encodeURIComponent(this.password)}`
|
||||
: null;
|
||||
const hasImage = !!macro.image_path;
|
||||
const firstChar = macro.name.charAt(0).toUpperCase();
|
||||
return `
|
||||
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
|
||||
${imageSrc ? `<img src="${imageSrc}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">` : ''}
|
||||
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>${firstChar}</div>
|
||||
${hasImage ? `<img class="macro-image" style="display:none">` : ''}
|
||||
<div class="macro-image-placeholder">${firstChar}</div>
|
||||
<span class="macro-name">${macro.name}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Load macro images via authenticated fetch (header, not URL).
|
||||
this.loadMacroImages();
|
||||
}
|
||||
|
||||
// Fetch each macro image with the password header and display it
|
||||
// as a blob object URL so the credential never appears in a URL.
|
||||
async loadMacroImages() {
|
||||
for (const [id, macro] of Object.entries(this.macros)) {
|
||||
if (!macro.image_path) continue;
|
||||
const card = document.querySelector(`[data-macro-id="${id}"]`);
|
||||
if (!card) continue;
|
||||
const img = card.querySelector('.macro-image');
|
||||
if (!img) continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/${this.sessionId}/api/image/${macro.image_path}`,
|
||||
{ headers: this.getApiHeaders() }
|
||||
);
|
||||
if (!response.ok) continue; // keep placeholder
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
img.onload = () => URL.revokeObjectURL(objectUrl);
|
||||
img.src = objectUrl;
|
||||
img.style.display = '';
|
||||
const placeholder = img.nextElementSibling;
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
} catch (error) {
|
||||
// Leave the placeholder visible on failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
|
||||
Reference in New Issue
Block a user