fixed CSRF danger for uploaded html files
This commit is contained in:
@@ -36,6 +36,8 @@ const upload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
const trustProxy = process.env.TRUST_PROXY === 'true';
|
||||||
|
app.set('trust proxy', trustProxy);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -110,6 +112,46 @@ function logEvent(event, owner, detail) {
|
|||||||
).catch(() => undefined);
|
).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) {
|
function parseLogins(contents) {
|
||||||
const entries = new Map();
|
const entries = new Map();
|
||||||
const lines = contents.split(/\r?\n/);
|
const lines = contents.split(/\r?\n/);
|
||||||
@@ -440,7 +482,7 @@ app.get(`${basePath}/login`, (req, res) => {
|
|||||||
res.send(renderPage('Anmeldung', body));
|
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 username = String(req.body.username || '').trim();
|
||||||
const password = String(req.body.password || '');
|
const password = String(req.body.password || '');
|
||||||
const logins = await loadLogins();
|
const logins = await loadLogins();
|
||||||
@@ -480,6 +522,7 @@ app.post(`${basePath}/login`, async (req, res) => {
|
|||||||
maxAge: jwtMaxAgeMs,
|
maxAge: jwtMaxAgeMs,
|
||||||
secure: process.env.COOKIE_SECURE === 'true',
|
secure: process.env.COOKIE_SECURE === 'true',
|
||||||
});
|
});
|
||||||
|
clearLoginAttempts('user', req);
|
||||||
await logEvent('login', username, { ok: true });
|
await logEvent('login', username, { ok: true });
|
||||||
res.redirect(baseUrl('/dashboard'));
|
res.redirect(baseUrl('/dashboard'));
|
||||||
});
|
});
|
||||||
@@ -519,7 +562,7 @@ app.get(`${basePath}/admin`, async (req, res) => {
|
|||||||
res.send(renderPage('Admin', body));
|
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) {
|
if (!adminHash) {
|
||||||
res.status(404).send(renderPage('Admin', '<p class="card">Admin-Zugang ist nicht konfiguriert.</p>'));
|
res.status(404).send(renderPage('Admin', '<p class="card">Admin-Zugang ist nicht konfiguriert.</p>'));
|
||||||
return;
|
return;
|
||||||
@@ -554,6 +597,7 @@ app.post(`${basePath}/admin/login`, async (req, res) => {
|
|||||||
maxAge: jwtMaxAgeMs,
|
maxAge: jwtMaxAgeMs,
|
||||||
secure: process.env.COOKIE_SECURE === 'true',
|
secure: process.env.COOKIE_SECURE === 'true',
|
||||||
});
|
});
|
||||||
|
clearLoginAttempts('admin', req);
|
||||||
await logEvent('admin_login', 'admin', { ok: true });
|
await logEvent('admin_login', 'admin', { ok: true });
|
||||||
res.redirect(baseUrl('/admin/dashboard'));
|
res.redirect(baseUrl('/admin/dashboard'));
|
||||||
});
|
});
|
||||||
@@ -598,20 +642,21 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
|
|||||||
const logRows = recentLogs.map((entry) => `
|
const logRows = recentLogs.map((entry) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${formatTimestamp(entry.created_at)}</td>
|
<td>${formatTimestamp(entry.created_at)}</td>
|
||||||
<td>${entry.event}</td>
|
<td>${escapeHtml(entry.event)}</td>
|
||||||
<td>${entry.owner || '—'}</td>
|
<td>${escapeHtml(entry.owner || '—')}</td>
|
||||||
<td>${entry.detail || ''}</td>
|
<td>${escapeHtml(entry.detail || '')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
const adminUploadsRows = allUploads.map((item) => {
|
const adminUploadsRows = allUploads.map((item) => {
|
||||||
const fileUrl = `/_share/${item.stored_name}`;
|
const fileUrl = `/_share/${item.stored_name}`;
|
||||||
|
const fileHref = encodeURI(fileUrl);
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${item.owner}</td>
|
<td>${escapeHtml(item.owner)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div><strong>${item.original_name}</strong></div>
|
<div><strong>${escapeHtml(item.original_name)}</strong></div>
|
||||||
<div class="muted"><a href="${fileUrl}" target="_blank" rel="noopener">${item.stored_name}</a></div>
|
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
||||||
</td>
|
</td>
|
||||||
<td>${formatBytes(item.size_bytes)}</td>
|
<td>${formatBytes(item.size_bytes)}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -712,23 +757,25 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
const rowForEntry = (entry, isDir) => {
|
const rowForEntry = (entry, isDir) => {
|
||||||
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
const childPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||||
const href = baseUrl(`/admin/files?path=${encodeURIComponent(childPath)}`);
|
const href = baseUrl(`/admin/files?path=${encodeURIComponent(childPath)}`);
|
||||||
|
const escapedName = escapeHtml(entry.name);
|
||||||
|
const escapedPath = escapeHtml(childPath);
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="name">
|
<span class="name">
|
||||||
${isDir ? '[DIR]' : '[FILE]'}
|
${isDir ? '[DIR]' : '[FILE]'}
|
||||||
${isDir ? `<a class="folder" href="${href}">${entry.name}</a>` : entry.name}
|
${isDir ? `<a class="folder" href="${href}">${escapedName}</a>` : escapedName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>${isDir ? 'Ordner' : 'Datei'}</td>
|
<td>${isDir ? 'Ordner' : 'Datei'}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<form method="post" action="${baseUrl('/admin/files/rename')}">
|
<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 />
|
<input name="newName" placeholder="Neuer Name" required />
|
||||||
<button type="submit">Umbenennen</button>
|
<button type="submit">Umbenennen</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="${baseUrl('/admin/files/delete')}">
|
<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>
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -757,7 +804,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="toolbar">
|
<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>` : ''}
|
${relativePath ? `<a class="tag" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">← Zurück</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -766,7 +813,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Ordner erstellen</h2>
|
<h2>Ordner erstellen</h2>
|
||||||
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
|
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
|
||||||
<input type="hidden" name="path" value="${relativePath}" />
|
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
|
||||||
<label>
|
<label>
|
||||||
Ordnername
|
Ordnername
|
||||||
<input name="name" placeholder="z.B. Projekte" required />
|
<input name="name" placeholder="z.B. Projekte" required />
|
||||||
@@ -777,7 +824,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Datei hochladen</h2>
|
<h2>Datei hochladen</h2>
|
||||||
<form method="post" action="${baseUrl('/admin/files/upload')}" enctype="multipart/form-data">
|
<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>
|
<label>
|
||||||
Datei
|
Datei
|
||||||
<input type="file" name="file" required />
|
<input type="file" name="file" required />
|
||||||
@@ -943,11 +990,12 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|||||||
|
|
||||||
const rows = uploads.map((item) => {
|
const rows = uploads.map((item) => {
|
||||||
const fileUrl = `/_share/${item.stored_name}`;
|
const fileUrl = `/_share/${item.stored_name}`;
|
||||||
|
const fileHref = encodeURI(fileUrl);
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div><strong>${item.original_name}</strong></div>
|
<div><strong>${escapeHtml(item.original_name)}</strong></div>
|
||||||
<div class="muted"><a href="${fileUrl}" target="_blank" rel="noopener">${item.stored_name}</a></div>
|
<div class="muted"><a href="${fileHref}" target="_blank" rel="noopener">${escapeHtml(item.stored_name)}</a></div>
|
||||||
</td>
|
</td>
|
||||||
<td>${formatBytes(item.size_bytes)}</td>
|
<td>${formatBytes(item.size_bytes)}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -955,7 +1003,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|||||||
<div class="muted">Noch ${formatCountdown(item.expires_at)}</div>
|
<div class="muted">Noch ${formatCountdown(item.expires_at)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<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`)}">
|
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
|
||||||
<button type="submit" class="secondary">Löschen</button>
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -972,7 +1020,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>Dateiverwaltung</h1>
|
<h1>Dateiverwaltung</h1>
|
||||||
<div class="muted">Angemeldet als ${req.user.username}</div>
|
<div class="muted">Angemeldet als ${escapeHtml(req.user.username)}</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="${baseUrl('/logout')}">
|
<form method="post" action="${baseUrl('/logout')}">
|
||||||
<button type="submit" class="secondary">Abmelden</button>
|
<button type="submit" class="secondary">Abmelden</button>
|
||||||
|
|||||||
@@ -42,4 +42,10 @@ IndexHeadInsert "<link rel=\\"stylesheet\\" href=\\"/icons/autoindex-custom.css\
|
|||||||
Require all granted\n\
|
Require all granted\n\
|
||||||
</Directory>\n' >> /usr/local/apache2/conf/httpd.conf
|
</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
|
EXPOSE 80
|
||||||
|
|||||||
Reference in New Issue
Block a user