diff --git a/SECURITY_AUDIT_RU.md b/SECURITY_AUDIT_RU.md index 325f1ad..f71d43f 100644 --- a/SECURITY_AUDIT_RU.md +++ b/SECURITY_AUDIT_RU.md @@ -26,12 +26,11 @@ function requireAdmin(req, res, next) { --- ### 2. Файлы доступны анонимно по токену -**Файлы:** `server.js:877-885`, `server.js:49` -- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен. -- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**. -- Share-ссылки (`/api/share/:token`) также выдают все файлы записи. +**Статус:** 🟢 Частично исправлено (share-файлы привязаны к ссылке) +- ✅ Публичные share-файлы теперь отдаются **только** через `GET /api/share/:shareToken/files/:fileToken`, где проверяется принадлежность файла к записям активной ссылки (expiry, пароль, фильтры группы/имени/периода) — `server.js:612-647`. +- ⚠️ `GET /api/files/:token` и статика `/uploads/*` по-прежнему доступны по токену (нужны для админ-панели). Токены криптостойкие (16 байт hex). -**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL. +**Файлы:** `server.js:612-647`, `public/share.html:106-121` --- diff --git a/public/index.html b/public/index.html index df3dfee..852a7fd 100644 --- a/public/index.html +++ b/public/index.html @@ -127,6 +127,8 @@ nav a:hover{color:var(--text)} + + @@ -314,6 +316,7 @@ form.addEventListener('submit', async (e) => { fd.append('student_name', studentName || document.getElementById('nameInput').value); fd.append('group_id', groupInput.value); fd.append('description', document.getElementById('descInput').value); + fd.append('website', document.getElementById('hpWebsite').value); try { const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd }); diff --git a/public/share.html b/public/share.html index ac59148..2165084 100644 --- a/public/share.html +++ b/public/share.html @@ -103,16 +103,17 @@ function fileIcon(t){ const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'}; return icons[t]||icons.other; } -function filesHTML(files, password){ +function filesHTML(files, password, shareToken){ if(!files||!files.length)return ''; const pw = password ? `?password=${encodeURIComponent(password)}` : ''; return `
Работы резидента
${files.map(f=>{ const t = fileType(f.name); + const url = `/api/share/${shareToken}/files/${f.token}${pw}`; return ` + ?`href="#" onclick="showImg('${url}');return false;"` + :`href="${url}" download`}> ${fileIcon(t)} `; }).join('')}
@@ -226,7 +227,7 @@ function renderShare(data, password) { ${!isAnonymized ? `
${esc(e.student_name)}
` : ''} ${!isAnonymized && e.group_name ? `
${esc(e.group_name)}
` : ''}
${esc(e.description)}
- ${filesHTML(e.files, password)} + ${filesHTML(e.files, password, currentToken)}
${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}
diff --git a/server.js b/server.js index abfa1cd..248459b 100644 --- a/server.js +++ b/server.js @@ -46,7 +46,7 @@ if (!ADMIN_PASSWORD) { } app.use(helmet({ contentSecurityPolicy: false })); -app.use(express.json()); +app.use(express.json({ limit: '1mb' })); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use(express.static(path.join(__dirname, 'public'))); @@ -186,12 +186,21 @@ app.put('/api/settings', requireAdmin, async (req, res) => { }); // --- Backup / Restore --- -const gunzipAsync = require('util').promisify(require('zlib').gunzip); +const gunzipFile = require('util').promisify(require('zlib').gunzipFile); const tar = require('tar'); const os = require('os'); const uploadBackup = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 300 * 1024 * 1024 }, + storage: multer.diskStorage({ + destination: (_, __, cb) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-up-')); + cb(null, dir); + }, + filename: (_, file, cb) => { + const ext = path.extname(file.originalname) || '.bin'; + cb(null, `backup-${Date.now()}${ext}`); + }, + }), + limits: { fileSize: 50 * 1024 * 1024 }, }); const SAFE_NAME = /^[\w,.()-]+$/; @@ -332,28 +341,34 @@ app.get('/api/backup', requireAdmin, async (_, res) => { } }); +function cleanupUpload(req) { + try { + if (req?.file?.destination) fs.rmSync(req.file.destination, { recursive: true, force: true }); + } catch {} +} + app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'backup file required' }); let data; let legacyPhotos = []; const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-')); try { - const gunz = await gunzipAsync(req.file.buffer).catch(() => null); + const gunz = await gunzipFile(req.file.path).catch(() => null); if (gunz && gunz[0] === 0x7b) { data = JSON.parse(gunz.toString('utf8')); legacyPhotos = data.photos || []; } else { - const inPath = path.join(staging, 'input.tar.gz'); - fs.writeFileSync(inPath, req.file.buffer); - await tar.x({ file: inPath, cwd: staging }); + await tar.x({ file: req.file.path, cwd: staging }); data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8')); } } catch { fs.rmSync(staging, { recursive: true, force: true }); + cleanupUpload(req); return res.status(400).json({ error: 'Неверный файл бэкапа' }); } if (!data || data.version !== 1 || !Array.isArray(data.groups)) { fs.rmSync(staging, { recursive: true, force: true }); + cleanupUpload(req); return res.status(400).json({ error: 'Неверный формат бэкапа' }); } let ndata; @@ -361,6 +376,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req ndata = normalizeRestoreData(data); } catch (e) { fs.rmSync(staging, { recursive: true, force: true }); + cleanupUpload(req); return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message }); } const client = await pool.connect(); @@ -407,6 +423,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } catch (e) { await client.query('ROLLBACK'); fs.rmSync(staging, { recursive: true, force: true }); + cleanupUpload(req); throw e; } finally { client.release(); @@ -429,6 +446,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } } fs.rmSync(staging, { recursive: true, force: true }); + cleanupUpload(req); await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err)); res.json({ ok: true }); }); @@ -609,6 +627,43 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => { }); }); +app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) => { + const { shareToken, fileToken } = req.params; + const { rows: shareRows } = await pool.query( + `SELECT l.* FROM share_links l WHERE l.token = $1`, [shareToken] + ); + if (!shareRows.length) return res.status(404).json({ error: 'Ссылка не найдена' }); + const l = shareRows[0]; + if (l.expires_at && new Date(l.expires_at) < new Date()) { + return res.status(410).json({ error: 'Срок действия ссылки истёк' }); + } + if (l.access_password_hash) { + const providedPassword = req.headers['x-share-password'] || req.query.password; + if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' }); + const valid = await bcrypt.compare(providedPassword, l.access_password_hash); + if (!valid) return res.status(401).json({ error: 'Неверный пароль' }); + } + const conditions = ['e.deleted_at IS NULL']; + const params = []; + if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); } + if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); } + if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); } + if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); } + params.push(fileToken); + const { rows } = await pool.query( + `SELECT pf.path, pf.name FROM project_files pf + JOIN entries e ON e.id = pf.entry_id + WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`, + params + ); + if (!rows.length) return res.status(404).json({ error: 'Not found' }); + const r = rows[0]; + const fp = path.join(__dirname, r.path); + if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' }); + if (isImageName(r.name)) res.sendFile(fp); + else res.download(fp, r.name); +}); + app.get('/s/:token', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'share.html')); }); @@ -1054,9 +1109,14 @@ app.post('/api/entries', entryLimiter, (req, res, next) => { return res.status(400).json({ error: 'Недопустимый файл' }); }); }, async (req, res) => { - const { student_name, group_id, description } = req.body; + const { student_name, group_id, description, website } = req.body; const photo = req.files?.photo?.[0] || null; const projectFiles = req.files?.files || []; + if (website) { + removeUpload(photo); + projectFiles.forEach(removeUpload); + return res.status(400).json({ error: 'Spam detected' }); + } if (!student_name?.trim() || !group_id || !description?.trim()) { removeUpload(photo); projectFiles.forEach(removeUpload);