switched user management to sqlite; better admin file browser; csrf protection for management interface using by csrf tokens

This commit is contained in:
Ludwig Lehnert
2026-01-12 20:06:41 +01:00
parent 83e9426c8c
commit 1f670df447
3 changed files with 246 additions and 58 deletions

View File

@@ -1,8 +0,0 @@
# This file stores user logins (and this file only)
# There is no other way to add user logins
# Comments in this file may only start at the very beginning of a line
# password is bcrypt of 123456
# the format per line is <username>;;<bcrypt hashed password>
foo@example.com;;$2a$12$JchPr84/tmKH2muqomK1qe/cj/X0PwcooA5ugynNn3HjU/wpxoNEe

View File

@@ -69,6 +69,7 @@ services:
- LOGIN_FILE=/app/.logins
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
- MANAGEMENT_ADMIN_HASH=${MANAGEMENT_ADMIN_HASH}
- TRUST_PROXY=true
- PORT=3000
volumes:

View File

@@ -17,7 +17,6 @@ const basePath = (process.env.BASE_PATH || '/manage').replace(/\/+$/, '') || '/m
const port = parseInt(process.env.PORT || '3000', 10);
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite');
const loginFile = process.env.LOGIN_FILE || path.join(__dirname, '..', '..', '.logins');
const adminHash = process.env.MANAGEMENT_ADMIN_HASH || '';
const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10);
const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10);
@@ -41,6 +40,10 @@ app.set('trust proxy', trustProxy);
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
const db = new sqlite3.Database(dbPath);
@@ -66,6 +69,11 @@ db.serialize(() => {
)`);
db.run('CREATE INDEX IF NOT EXISTS admin_logs_event_idx ON admin_logs(event)');
db.run('CREATE INDEX IF NOT EXISTS admin_logs_created_idx ON admin_logs(created_at)');
db.run(`CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)`);
});
function run(sql, params = []) {
@@ -112,6 +120,13 @@ function logEvent(event, owner, detail) {
).catch(() => undefined);
}
const csrfCookieName = 'csrf';
const csrfCookieOptions = {
httpOnly: true,
sameSite: 'strict',
secure: process.env.COOKIE_SECURE === 'true',
};
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
@@ -121,6 +136,60 @@ function escapeHtml(value) {
.replace(/'/g, '&#39;');
}
function csrfField(token) {
return `<input type="hidden" name="csrfToken" value="${escapeHtml(token)}" />`;
}
function ensureCsrfToken(req, res, next) {
let token = req.cookies[csrfCookieName];
if (!token) {
token = crypto.randomBytes(32).toString('hex');
res.cookie(csrfCookieName, token, csrfCookieOptions);
}
res.locals.csrfToken = token;
next();
}
function isSameOrigin(req) {
const origin = req.get('origin');
const referer = req.get('referer');
const header = origin || referer;
if (!header) {
return false;
}
try {
const parsed = new URL(header);
const expected = `${req.protocol}://${req.get('host')}`;
return parsed.origin === expected;
} catch (err) {
return false;
}
}
function csrfGuard(req, res, next) {
if (!isSameOrigin(req)) {
if (req.path.startsWith(`${basePath}/api/`)) {
res.status(403).json({ error: 'Origin check failed' });
return;
}
res.status(403).send(renderPage('Zugriff verweigert', '<p class="card">Origin-Prüfung fehlgeschlagen.</p>'));
return;
}
const token = req.cookies[csrfCookieName];
const provided = req.body?.csrfToken || req.get('x-csrf-token');
if (!token || !provided || token !== provided) {
if (req.path.startsWith(`${basePath}/api/`)) {
res.status(403).json({ error: 'CSRF token mismatch' });
return;
}
res.status(403).send(renderPage('Zugriff verweigert', '<p class="card">CSRF-Prüfung fehlgeschlagen.</p>'));
return;
}
next();
}
const loginAttempts = new Map();
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_ATTEMPTS = 10;
@@ -152,35 +221,9 @@ function clearLoginAttempts(type, req) {
loginAttempts.delete(`${type}:${ip}`);
}
function parseLogins(contents) {
const entries = new Map();
const lines = contents.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const parts = line.split(';;');
if (parts.length !== 2) {
continue;
}
const username = parts[0].trim();
const hash = parts[1].trim();
if (!username || !hash) {
continue;
}
entries.set(username, hash);
}
return entries;
}
async function loadLogins() {
try {
const contents = await fs.promises.readFile(loginFile, 'utf8');
return parseLogins(contents);
} catch (err) {
return new Map();
}
async function getUserHash(username) {
const row = await get('SELECT password_hash FROM users WHERE username = ?', [username]);
return row ? row.password_hash : null;
}
function toBase32(buffer) {
@@ -274,31 +317,34 @@ function renderFileManagerPage(title, body) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root { --ink:#0b0f19; --muted:#556070; --line:#dfe4ea; --bg:#f2f4f7; --card:#ffffff; --accent:#0f766e; }
:root { --ink:#0b0f19; --muted:#5b6470; --line:#d8dde4; --bg:#eef1f5; --card:#ffffff; --accent:#0f766e; --accent-strong:#0a5b55; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Gill Sans", "Trebuchet MS", sans-serif; background: var(--bg); color: var(--ink); }
main { max-width: 1280px; margin: 0 auto; padding: 24px 18px 64px; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
h1 { margin: 0; font-size: 1.7rem; letter-spacing: 0.02em; }
h2 { margin: 0 0 12px; font-size: 1.1rem; }
body { margin: 0; font-family: "IBM Plex Sans", "Noto Sans", sans-serif; background: var(--bg); color: var(--ink); }
main { max-width: 1280px; margin: 0 auto; padding: 26px 18px 70px; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(135deg, #ffffff 0%, #f6fbfa 100%); border: 1px solid var(--line); }
h1 { margin: 0; font-size: 1.75rem; letter-spacing: 0.01em; }
h2 { margin: 0 0 12px; font-size: 1.08rem; }
.muted { color: var(--muted); font-size: 0.95rem; }
.card { margin-top: 16px; padding: 16px; background: var(--card); border-radius: 14px; border: 1px solid var(--line); }
.card { margin-top: 18px; padding: 16px; background: var(--card); border-radius: 16px; border: 1px solid var(--line); box-shadow: 0 8px 26px rgba(12, 18, 28, 0.08); }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border-radius: 999px; background: #e8f0f2; color: var(--ink); text-decoration: none; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px; border-radius: 999px; background: #f1f5f9; border: 1px solid var(--line); color: var(--ink); text-decoration: none; font-weight: 600; }
.tag span { color: var(--muted); font-weight: 500; }
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, button, select { font: inherit; padding: 8px 10px; border-radius: 8px; }
input, button, select { font: inherit; padding: 9px 12px; border-radius: 10px; }
input, select { border: 1px solid var(--line); background: #fff; }
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; transition: transform 0.15s ease, background 0.2s ease; }
button:hover { background: var(--accent-strong); }
button.secondary { background: transparent; color: var(--accent); }
.tag { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; border-radius: 999px; background: #f1f5f9; border: 1px solid var(--line); color: var(--ink); text-decoration: none; }
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
.name { display: inline-flex; align-items: center; gap: 8px; }
.folder { font-weight: 700; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr:nth-child(even) { background: #f9fbfc; }
.name { display: inline-flex; align-items: center; gap: 10px; }
.name strong { font-weight: 700; }
.folder { font-weight: 700; color: var(--accent-strong); text-decoration: none; }
.actions { display: grid; gap: 6px; }
.actions input, .actions button { font-size: 0.9rem; padding: 6px 8px; }
.actions input, .actions button { font-size: 0.9rem; padding: 7px 10px; }
</style>
</head>
<body>
@@ -438,6 +484,15 @@ async function cleanupExpired() {
}
}
app.use(ensureCsrfToken);
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
csrfGuard(req, res, next);
return;
}
next();
});
const cleanupTimer = setInterval(() => {
cleanupExpired().catch(() => undefined);
}, 60 * 1000);
@@ -467,6 +522,7 @@ app.get(`${basePath}/login`, (req, res) => {
</header>
<section class="card">
<form method="post" action="${baseUrl('/login')}">
${csrfField(res.locals.csrfToken)}
<label>
Benutzername
<input name="username" autocomplete="username" required />
@@ -485,8 +541,7 @@ app.get(`${basePath}/login`, (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();
const hash = logins.get(username);
const hash = await getUserHash(username);
if (!hash || !bcrypt.compareSync(password, hash)) {
const body = `
@@ -499,6 +554,7 @@ app.post(`${basePath}/login`, loginRateLimit('user'), async (req, res) => {
<section class="card">
<div class="pill">Anmeldung fehlgeschlagen</div>
<form method="post" action="${baseUrl('/login')}">
${csrfField(res.locals.csrfToken)}
<label>
Benutzername
<input name="username" autocomplete="username" required />
@@ -551,6 +607,7 @@ app.get(`${basePath}/admin`, async (req, res) => {
</header>
<section class="card">
<form method="post" action="${baseUrl('/admin/login')}">
${csrfField(res.locals.csrfToken)}
<label>
Admin-Passwort
<input type="password" name="password" autocomplete="current-password" required />
@@ -579,6 +636,7 @@ app.post(`${basePath}/admin/login`, loginRateLimit('admin'), async (req, res) =>
<section class="card">
<div class="pill">Anmeldung fehlgeschlagen</div>
<form method="post" action="${baseUrl('/admin/login')}">
${csrfField(res.locals.csrfToken)}
<label>
Admin-Passwort
<input type="password" name="password" autocomplete="current-password" required />
@@ -665,9 +723,11 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
</td>
<td class="actions">
<form method="post" action="${baseUrl(`/admin/files/${item.id}/delete`)}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Löschen</button>
</form>
<form method="post" action="${baseUrl(`/admin/files/${item.id}/extend`)}">
${csrfField(res.locals.csrfToken)}
<input name="extendHours" placeholder="Stunden hinzufügen" />
<button type="submit">Verlängern</button>
</form>
@@ -684,7 +744,9 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
</div>
<div class="toolbar">
<a class="tag" href="${baseUrl('/admin/files')}">Dateimanager</a>
<a class="tag" href="${baseUrl('/admin/users')}">Benutzer verwalten</a>
<form method="post" action="${baseUrl('/admin/logout')}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Abmelden</button>
</form>
</div>
@@ -733,6 +795,130 @@ app.get(`${basePath}/admin/dashboard`, requireAdminPage, async (req, res) => {
res.send(renderPage('Adminübersicht', body, 'wide'));
});
app.get(`${basePath}/admin/users`, requireAdminPage, async (req, res) => {
const users = await all('SELECT username, created_at FROM users ORDER BY username ASC');
const rows = users.map((user) => {
const username = escapeHtml(user.username);
const encoded = encodeURIComponent(user.username);
return `
<tr>
<td>${username}</td>
<td>${formatTimestamp(user.created_at)}</td>
<td class="actions">
<form method="post" action="${baseUrl(`/admin/users/${encoded}/reset`)}">
${csrfField(res.locals.csrfToken)}
<input name="password" type="password" placeholder="Neues Passwort" required />
<button type="submit">Passwort setzen</button>
</form>
<form method="post" action="${baseUrl(`/admin/users/${encoded}/delete`)}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Löschen</button>
</form>
</td>
</tr>
`;
}).join('');
const body = `
<header>
<div>
<h1>Benutzer verwalten</h1>
<div class="muted">Zugänge im System verwalten.</div>
</div>
<div class="toolbar">
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
<form method="post" action="${baseUrl('/admin/logout')}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Abmelden</button>
</form>
</div>
</header>
<section class="card">
<h2>Neuen Benutzer anlegen</h2>
<form method="post" action="${baseUrl('/admin/users/create')}">
${csrfField(res.locals.csrfToken)}
<label>
Benutzername
<input name="username" autocomplete="username" required />
</label>
<label>
Passwort
<input name="password" type="password" autocomplete="new-password" required />
</label>
<button type="submit">Erstellen</button>
</form>
</section>
<section class="card">
<h2>Benutzerliste</h2>
${users.length ? `
<table>
<thead>
<tr>
<th>Benutzername</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
` : '<div class="muted">Keine Benutzer vorhanden.</div>'}
</section>
`;
res.send(renderPage('Benutzer verwalten', body, 'wide'));
});
app.post(`${basePath}/admin/users/create`, requireAdminPage, async (req, res) => {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
if (!username || username.length > 200 || !password) {
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
return;
}
const hash = bcrypt.hashSync(password, 12);
try {
await run('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)', [
username,
hash,
Date.now(),
]);
} catch (err) {
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Benutzername existiert bereits.</p>', 'wide'));
return;
}
await logEvent('admin_user_create', 'admin', { username });
res.redirect(baseUrl('/admin/users'));
});
app.post(`${basePath}/admin/users/:username/reset`, requireAdminPage, async (req, res) => {
const username = decodeURIComponent(req.params.username || '');
const password = String(req.body.password || '');
if (!username || !password) {
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
return;
}
const hash = bcrypt.hashSync(password, 12);
await run('UPDATE users SET password_hash = ? WHERE username = ?', [hash, username]);
await logEvent('admin_user_reset', 'admin', { username });
res.redirect(baseUrl('/admin/users'));
});
app.post(`${basePath}/admin/users/:username/delete`, requireAdminPage, async (req, res) => {
const username = decodeURIComponent(req.params.username || '');
if (!username) {
res.status(400).send(renderPage('Benutzer verwalten', '<p class="card">Ungültige Eingabe.</p>', 'wide'));
return;
}
await run('DELETE FROM users WHERE username = ?', [username]);
await logEvent('admin_user_delete', 'admin', { username });
res.redirect(baseUrl('/admin/users'));
});
app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
const relativePath = String(req.query.path || '').replace(/^\/+/, '');
const resolved = resolveAdminPath(relativePath);
@@ -763,18 +949,20 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<tr>
<td>
<span class="name">
${isDir ? '[DIR]' : '[FILE]'}
${isDir ? '<strong>DIR</strong>' : 'FILE'}
${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')}">
${csrfField(res.locals.csrfToken)}
<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')}">
${csrfField(res.locals.csrfToken)}
<input type="hidden" name="path" value="${escapedPath}" />
<button type="submit" class="secondary">Löschen</button>
</form>
@@ -797,6 +985,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<div class="toolbar">
<a class="tag" href="${baseUrl('/admin/dashboard')}">Zur Adminübersicht</a>
<form method="post" action="${baseUrl('/admin/logout')}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Abmelden</button>
</form>
</div>
@@ -804,7 +993,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<section class="card">
<div class="toolbar">
<span class="tag">Pfad: /${escapeHtml(relativePath || '')}</span>
<span class="tag">Pfad <span>/${escapeHtml(relativePath || '')}</span></span>
${relativePath ? `<a class="tag" href="${baseUrl(`/admin/files?path=${encodeURIComponent(parentPath)}`)}">&larr; Zurück</a>` : ''}
</div>
</section>
@@ -813,6 +1002,7 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<div>
<h2>Ordner erstellen</h2>
<form method="post" action="${baseUrl('/admin/files/mkdir')}">
${csrfField(res.locals.csrfToken)}
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
<label>
Ordnername
@@ -824,6 +1014,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">
${csrfField(res.locals.csrfToken)}
<input type="hidden" name="path" value="${escapeHtml(relativePath)}" />
<label>
Datei
@@ -1005,9 +1196,11 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<td class="actions">
<button type="button" class="secondary copy-link" data-path="${fileHref}">Link kopieren</button>
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Löschen</button>
</form>
<form method="post" action="${baseUrl(`/files/${item.id}/extend`)}">
${csrfField(res.locals.csrfToken)}
<input name="extendHours" placeholder="Stunden hinzufügen" />
<button type="submit">Verlängern</button>
</form>
@@ -1023,6 +1216,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<div class="muted">Angemeldet als ${escapeHtml(req.user.username)}</div>
</div>
<form method="post" action="${baseUrl('/logout')}">
${csrfField(res.locals.csrfToken)}
<button type="submit" class="secondary">Abmelden</button>
</form>
</header>
@@ -1030,6 +1224,7 @@ app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => {
<section class="card">
<h2>Datei hochladen</h2>
<form id="upload-form">
${csrfField(res.locals.csrfToken)}
<label>
Datei
<input type="file" name="file" required />