Add customer file browser, configurable WebDAV URL and harden both

- /u/:token/files lists files in the customer folder, /u/:token/file
  streams a download. Iterative walker with depth limit; symlinks are
  rejected at enumeration and via realpath containment on download;
  Content-Disposition filename is sanitized with an RFC 5987 fallback
- New "Private WebDAV-URL" field in admin settings, displayed under
  the customer table. Served via authenticated /status (not public
  /branding) so it does not leak to upload visitors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-16 14:14:05 +02:00
parent 2b1417ccf3
commit 386855d76a
3 changed files with 170 additions and 6 deletions
+78 -2
View File
@@ -138,11 +138,14 @@ publicApi.get('/branding', (req, res) => {
publicApi.get('/status', (req, res) => {
const u = auth.getSessionUser(req);
res.json({
const payload = {
setup_required: !auth.hasAnyUser(),
authenticated: !!u,
user: u ? { id: u.id, username: u.username, role: u.role } : null,
});
};
// WebDAV URL is internal infra info — only expose it to authenticated users.
if (u) payload.webdav_url = settings.get('webdav_url', '');
res.json(payload);
});
publicApi.post('/setup', loginLimiter, async (req, res) => {
@@ -261,6 +264,7 @@ api.post('/me/password', loginLimiter, auth.requireAuth, async (req, res) => {
api.get('/settings', auth.requireAdmin, (req, res) => {
res.json({
public_base_url: settings.get('public_base_url', ''),
webdav_url: settings.get('webdav_url', ''),
janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10),
logo_filename: settings.get('logo_filename', ''),
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
@@ -281,6 +285,9 @@ api.put('/settings', auth.requireAdmin, (req, res) => {
if (b.public_base_url !== undefined) {
settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
}
if (b.webdav_url !== undefined) {
settings.set('webdav_url', String(b.webdav_url || '').trim().replace(/\/+$/, ''));
}
if (b.janitor_interval_minutes !== undefined) {
const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
settings.set('janitor_interval_minutes', String(n));
@@ -591,6 +598,75 @@ function uploadAuth(req, res, next) {
}
}
const MAX_WALK_DEPTH = 20;
// Iterative walk with depth limit. Symlinks (file or directory) are skipped
// so a malicious WebDAV upload cannot point out of the customer directory.
function listCustomerFiles(baseDir) {
const out = [];
const queue = [{ dir: baseDir, depth: 0 }];
while (queue.length) {
const { dir, depth } = queue.shift();
if (depth > MAX_WALK_DEPTH) continue;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
for (const e of entries) {
if (e.isSymbolicLink()) continue;
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
queue.push({ dir: abs, depth: depth + 1 });
} else if (e.isFile()) {
try {
const st = fs.lstatSync(abs);
if (!st.isFile()) continue;
out.push({
path: path.relative(baseDir, abs).split(path.sep).join('/'),
size: st.size,
mtime: st.mtimeMs,
});
} catch {}
}
}
}
return out.sort((a, b) => b.mtime - a.mtime);
}
// Strip control chars / CRLF / quotes for Content-Disposition.
// Anything non-ASCII-safe is replaced with "_"; the original is offered
// via RFC 5987 filename* so international names still work in modern clients.
function cdFilename(name) {
const safe = String(name).replace(/[\x00-\x1f\x7f"\\]+/g, '_').slice(0, 200) || 'download';
const encoded = encodeURIComponent(name);
return `attachment; filename="${safe}"; filename*=UTF-8''${encoded}`;
}
app.get('/u/:token/files', uploadAuth, (req, res) => {
const c = req._customer;
res.json(listCustomerFiles(customerDir(c.slug)));
});
app.get('/u/:token/file', uploadAuth, (req, res) => {
const c = req._customer;
const base = customerDir(c.slug);
const rel = sanitizeRelPath(req.query.path || '');
if (!rel) return res.status(400).end();
let abs;
try { abs = safeJoin(base, rel); } catch { return res.status(400).end(); }
// Defeat symlinks: resolve the real path and re-check containment.
let real;
try { real = fs.realpathSync(abs); } catch { return res.status(404).end(); }
const baseReal = fs.realpathSync(base);
if (real !== baseReal && !real.startsWith(baseReal + path.sep)) {
return res.status(404).end();
}
let st;
try { st = fs.lstatSync(real); } catch { return res.status(404).end(); }
if (!st.isFile()) return res.status(404).end();
res.setHeader('Content-Disposition', cdFilename(path.basename(real)));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.sendFile(real);
});
app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
const c = req._customer;
const f = req.file;