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:
+78
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user