fixed CSRF danger for uploaded html files

This commit is contained in:
Ludwig Lehnert
2026-01-12 19:52:02 +01:00
parent d2348a4875
commit 83e9426c8c
2 changed files with 72 additions and 18 deletions

View File

@@ -36,6 +36,8 @@ const upload = multer({
});
const app = express();
const trustProxy = process.env.TRUST_PROXY === 'true';
app.set('trust proxy', trustProxy);
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
@@ -110,6 +112,46 @@ function logEvent(event, owner, detail) {
).catch(() => undefined);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const loginAttempts = new Map();
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_ATTEMPTS = 10;
function loginRateLimit(type) {
return (req, res, next) => {
const ip = req.ip || 'unknown';
const key = `${type}:${ip}`;
const now = Date.now();
const entry = loginAttempts.get(key) || { count: 0, resetAt: now + LOGIN_WINDOW_MS };
if (now > entry.resetAt) {
entry.count = 0;
entry.resetAt = now + LOGIN_WINDOW_MS;
}
entry.count += 1;
loginAttempts.set(key, entry);
if (entry.count > LOGIN_MAX_ATTEMPTS) {
const waitMinutes = Math.ceil((entry.resetAt - now) / 60000);
const body = `<section class="card"><p>Zu viele Anmeldeversuche. Bitte in ${waitMinutes} Minuten erneut versuchen.</p></section>`;
res.status(429).send(renderPage('Zu viele Versuche', body));
return;
}
next();
};
}
function clearLoginAttempts(type, req) {
const ip = req.ip || 'unknown';
loginAttempts.delete(`${type}:${ip}`);
}
function parseLogins(contents) {
const entries = new Map();
const lines = contents.split(/\r?\n/);
@@ -440,7 +482,7 @@ app.get(`${basePath}/login`, (req, res) => {
res.send(renderPage('Anmeldung', body));
});
app.post(`${basePath}/login`, async (req, res) => {
app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const logins = await loadLogins();
@@ -480,6 +522,7 @@ app.post(`${basePath}/login`, async (req, res) => {
maxAge: jwtMaxAgeMs,
secure: process.env.COOKIE_SECURE === 'true',
});
clearLoginAttempts('user', req);
await logEvent('login', username, { ok: true });
res.redirect(baseUrl('/dashboard'));
});
@@ -519,7 +562,7 @@ app.get(`${basePath}/admin`, async (req, res) => {
res.send(renderPage('Admin', body));
});
app.post(`${basePath}/admin/login`, async (req, res) => {
app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) => {
if (!adminHash) {
res.status(404).send(renderPage('Admin', '<p class="card">Admin-Zugang ist nicht konfiguriert.</p>'));
return;
@@ -554,6 +597,7 @@ app.post(`${basePath}/admin/login`, async (req, res) => {
maxAge: jwtMaxAgeMs,
secure: process.env.COOKIE_SECURE === 'true',
});
clearLoginAttempts('admin', req);
await logEvent('admin_login', 'admin', { ok: true });
res.redirect(baseUrl('/admin/dashboard'));
});
@@ -598,20 +642,21 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
const logRows = recentLogs.map((entry) => `
<tr>
<td>${formatTimestamp(entry.created_at)}</td>
<td>${entry.event}</td>
<td>${entry.owner || '—'}</td>
<td>${entry.detail || ''}</td>
<td>${escapeHtml(entry.event)}</td>
<td>${escapeHtml(entry.owner || '—')}</td>
<td>${escapeHtml(entry.detail || '')}</td>
</tr>
`).join('');
const adminUploadsRows = allUploads.map((item) => {
const fileUrl = `/_share/${item.stored_name}`;
const fileHref = encodeURI(fileUrl);
return `
<tr>
<td>${item.owner}</td>
<td>${escapeHtml(item.owner)}</td>
<td>
<div><strong>${item.original_name}</strong></div>
<div class="muted"><a href="${fileUrl}" target="_blank" rel="noopener">${item.stored_name}</a></div>
<div><strong>${escapeHtml(item.original_name)}</strong></div>
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
</td>
<td>${formatBytes(item.size_bytes)}</td>
<td>
@@ -712,23 +757,25 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
const rowForEntry = (entry, isDir) => {
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const href = baseUrl(`/admin/files?path=${encodeURIComponent(childPath)}`);
const escapedName = escapeHtml(entry.name);
const escapedPath = escapeHtml(childPath);
return `
<tr>
<td>
<span class="name">
${isDir ? '[DIR]' : '[FILE]'}
${isDir ? `<a class="folder" href="${href}">${entry.name}</a>` : entry.name}
${isDir ? `<a class="folder" href="${href}">${escapedName}</a>` : escapedName}
</span>
</td>
<td>${isDir ? 'Ordner' : 'Datei'}</td>
<td class="actions">
<form method="post" action="${baseUrl('/admin/files/rename')}">
<input type="hidden" name="path" value="${childPath}" />
<input type="hidden" name="path" value="${escapedPath}" />
<input name="newName" placeholder="Neuer Name" required />
<button type="submit">Umbenennen</button>
</form>
<form method="post" action="${baseUrl('/admin/files/delete')}">
<input type="hidden" name="path" value="${childPath}" />
<input type="hidden" name="path" value="${escapedPath}" />
<button type="submit" class="secondary">Löschen</button>
</form>
</td>
@@ -757,7 +804,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<section class="card">
<div class="toolbar">
<span class="tag">Pfad: /${relativePath || ''}</span>
<span class="tag">Pfad: /${escapeHtml(relativePath || '')}</span>
${relativePath ? `<a class="tag" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">&larr; Zurück</a>` : ''}
</div>
</section>
@@ -766,7 +813,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<div>
<h2>Ordner erstellen</h2>
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
<input type="hidden" name="path" value="${relativePath}" />
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
<label>
Ordnername
<input name="name" placeholder="z.B. Projekte" required />
@@ -777,7 +824,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<div>
<h2>Datei hochladen</h2>
<form method="post" action="${baseUrl('/admin/files/upload')}" enctype="multipart/form-data">
<input type="hidden" name="path" value="${relativePath}" />
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
<label>
Datei
<input type="file" name="file" required />
@@ -943,11 +990,12 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
const rows = uploads.map((item) => {
const fileUrl = `/_share/${item.stored_name}`;
const fileHref = encodeURI(fileUrl);
return `
<tr>
<td>
<div><strong>${item.original_name}</strong></div>
<div class="muted"><a href="${fileUrl}" target="_blank" rel="noopener">${item.stored_name}</a></div>
<div><strong>${escapeHtml(item.original_name)}</strong></div>
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
</td>
<td>${formatBytes(item.size_bytes)}</td>
<td>
@@ -955,7 +1003,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<div class="muted">Noch ${formatCountdown(item.expires_at)}</div>
</td>
<td class="actions">
<button type="button" class="secondary copy-link" data-path="${fileUrl}">Link kopieren</button>
<button type="button" class="secondary copy-link" data-path="${fileHref}">Link kopieren</button>
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
<button type="submit" class="secondary">Löschen</button>
</form>
@@ -972,7 +1020,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<header>
<div>
<h1>Dateiverwaltung</h1>
<div class="muted">Angemeldet als ${req.user.username}</div>
<div class="muted">Angemeldet als ${escapeHtml(req.user.username)}</div>
</div>
<form method="post" action="${baseUrl('/logout')}">
<button type="submit" class="secondary">Abmelden</button>

View File

@@ -42,4 +42,10 @@ IndexHeadInsert "<link rel=\\"stylesheet\\" href=\\"/icons/autoindex-custom.css\
Require all granted\n\
</Directory>\n' >> /usr/local/apache2/conf/httpd.conf
# Force download for shared files
RUN printf '\n# --- Force download in _share ---\n\
<Directory "/usr/local/apache2/htdocs/_share">\n\
Header set Content-Disposition "attachment"\n\
</Directory>\n' >> /usr/local/apache2/conf/httpd.conf
EXPOSE 80