harden anti-spam and file serving; backup restore to disk; json body limit
- Add honeypot field to public submission form + server-side check - Serve shared files only in context of a valid share link (/api/share/:shareToken/files/:fileToken) - Switch backup restore upload to diskStorage (50MB) with temp-dir cleanup - Limit JSON body to 1mb - Document fixed audit items
This commit is contained in:
@@ -26,12 +26,11 @@ function requireAdmin(req, res, next) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2. Файлы доступны анонимно по токену
|
### 2. Файлы доступны анонимно по токену
|
||||||
**Файлы:** `server.js:877-885`, `server.js:49`
|
**Статус:** 🟢 Частично исправлено (share-файлы привязаны к ссылке)
|
||||||
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
|
- ✅ Публичные share-файлы теперь отдаются **только** через `GET /api/share/:shareToken/files/:fileToken`, где проверяется принадлежность файла к записям активной ссылки (expiry, пароль, фильтры группы/имени/периода) — `server.js:612-647`.
|
||||||
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
|
- ⚠️ `GET /api/files/:token` и статика `/uploads/*` по-прежнему доступны по токену (нужны для админ-панели). Токены криптостойкие (16 байт hex).
|
||||||
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
|
|
||||||
|
|
||||||
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
|
**Файлы:** `server.js:612-647`, `public/share.html:106-121`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ nav a:hover{color:var(--text)}
|
|||||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
||||||
|
|
||||||
<button class="btn" type="submit" id="submitBtn">Отправить</button>
|
<button class="btn" type="submit" id="submitBtn">Отправить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -314,6 +316,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
fd.append('group_id', groupInput.value);
|
fd.append('group_id', groupInput.value);
|
||||||
fd.append('description', document.getElementById('descInput').value);
|
fd.append('description', document.getElementById('descInput').value);
|
||||||
|
fd.append('website', document.getElementById('hpWebsite').value);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
||||||
|
|||||||
+5
-4
@@ -103,16 +103,17 @@ function fileIcon(t){
|
|||||||
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
||||||
return icons[t]||icons.other;
|
return icons[t]||icons.other;
|
||||||
}
|
}
|
||||||
function filesHTML(files, password){
|
function filesHTML(files, password, shareToken){
|
||||||
if(!files||!files.length)return '';
|
if(!files||!files.length)return '';
|
||||||
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
|
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
|
||||||
return `<div class="files-block">
|
return `<div class="files-block">
|
||||||
<div class="files-label">Работы резидента</div>
|
<div class="files-label">Работы резидента</div>
|
||||||
<div class="files">${files.map(f=>{
|
<div class="files">${files.map(f=>{
|
||||||
const t = fileType(f.name);
|
const t = fileType(f.name);
|
||||||
|
const url = `/api/share/${shareToken}/files/${f.token}${pw}`;
|
||||||
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
||||||
?`href="#" onclick="showImg('/api/files/${f.token}${pw}');return false;"`
|
?`href="#" onclick="showImg('${url}');return false;"`
|
||||||
:`href="/api/files/${f.token}${pw}" download`}>
|
:`href="${url}" download`}>
|
||||||
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
||||||
</a>`;
|
</a>`;
|
||||||
}).join('')}</div>
|
}).join('')}</div>
|
||||||
@@ -226,7 +227,7 @@ function renderShare(data, password) {
|
|||||||
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
||||||
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
||||||
<div class="desc">${esc(e.description)}</div>
|
<div class="desc">${esc(e.description)}</div>
|
||||||
${filesHTML(e.files, password)}
|
${filesHTML(e.files, password, currentToken)}
|
||||||
</div>
|
</div>
|
||||||
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
|
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ if (!ADMIN_PASSWORD) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.use(helmet({ contentSecurityPolicy: false }));
|
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('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
@@ -186,12 +186,21 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Backup / Restore ---
|
// --- Backup / Restore ---
|
||||||
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
const gunzipFile = require('util').promisify(require('zlib').gunzipFile);
|
||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const uploadBackup = multer({
|
const uploadBackup = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.diskStorage({
|
||||||
limits: { fileSize: 300 * 1024 * 1024 },
|
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,.()-]+$/;
|
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) => {
|
app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'backup file required' });
|
if (!req.file) return res.status(400).json({ error: 'backup file required' });
|
||||||
let data;
|
let data;
|
||||||
let legacyPhotos = [];
|
let legacyPhotos = [];
|
||||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-'));
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-'));
|
||||||
try {
|
try {
|
||||||
const gunz = await gunzipAsync(req.file.buffer).catch(() => null);
|
const gunz = await gunzipFile(req.file.path).catch(() => null);
|
||||||
if (gunz && gunz[0] === 0x7b) {
|
if (gunz && gunz[0] === 0x7b) {
|
||||||
data = JSON.parse(gunz.toString('utf8'));
|
data = JSON.parse(gunz.toString('utf8'));
|
||||||
legacyPhotos = data.photos || [];
|
legacyPhotos = data.photos || [];
|
||||||
} else {
|
} else {
|
||||||
const inPath = path.join(staging, 'input.tar.gz');
|
await tar.x({ file: req.file.path, cwd: staging });
|
||||||
fs.writeFileSync(inPath, req.file.buffer);
|
|
||||||
await tar.x({ file: inPath, cwd: staging });
|
|
||||||
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
|
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
cleanupUpload(req);
|
||||||
return res.status(400).json({ error: 'Неверный файл бэкапа' });
|
return res.status(400).json({ error: 'Неверный файл бэкапа' });
|
||||||
}
|
}
|
||||||
if (!data || data.version !== 1 || !Array.isArray(data.groups)) {
|
if (!data || data.version !== 1 || !Array.isArray(data.groups)) {
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
cleanupUpload(req);
|
||||||
return res.status(400).json({ error: 'Неверный формат бэкапа' });
|
return res.status(400).json({ error: 'Неверный формат бэкапа' });
|
||||||
}
|
}
|
||||||
let ndata;
|
let ndata;
|
||||||
@@ -361,6 +376,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
ndata = normalizeRestoreData(data);
|
ndata = normalizeRestoreData(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
cleanupUpload(req);
|
||||||
return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message });
|
return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message });
|
||||||
}
|
}
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@@ -407,6 +423,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
cleanupUpload(req);
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -429,6 +446,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs.rmSync(staging, { recursive: true, force: true });
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
cleanupUpload(req);
|
||||||
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
|
||||||
res.json({ ok: true });
|
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) => {
|
app.get('/s/:token', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'share.html'));
|
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: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
});
|
});
|
||||||
}, async (req, res) => {
|
}, 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 photo = req.files?.photo?.[0] || null;
|
||||||
const projectFiles = req.files?.files || [];
|
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()) {
|
if (!student_name?.trim() || !group_id || !description?.trim()) {
|
||||||
removeUpload(photo);
|
removeUpload(photo);
|
||||||
projectFiles.forEach(removeUpload);
|
projectFiles.forEach(removeUpload);
|
||||||
|
|||||||
Reference in New Issue
Block a user