fixed CSRF danger for uploaded html files
This commit is contained in:
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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)}`)}">← 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user