Address the open CodeQL code-scanning alerts:
- guestWifi voucher codes now use rejection sampling so the character
distribution is uniform (fixes js/biased-cryptographic-random).
- Scheduled-report recipient validation replaced the backtracking email regex
with a linear split-based check (fixes js/polynomial-redos).
- Add .github/codeql/codeql-config.yml excluding js/missing-rate-limiting:
rate limiting is applied globally via app.use('/api', rateLimitRedis) plus
per-route limiters, which CodeQL can't attribute to individual handlers, so
that rule fired ~38 false positives.
Remaining alerts are dismissed as accepted/false-positive (RouterOS MD5 API
login and self-signed cert acceptance are protocol-mandated; log sites already
strip CRLF).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
8.4 KiB
TypeScript
171 lines
8.4 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { randomBytes, createHash } from 'crypto';
|
|
import { query, queryOne } from '../config/database';
|
|
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
|
|
import { webhookService, WEBHOOK_EVENTS } from '../services/WebhookService';
|
|
import { reportService, computeNextRun } from '../services/ReportService';
|
|
|
|
const router = Router();
|
|
router.use(requireAuth);
|
|
|
|
// Linear email check — no backtracking regex (avoids ReDoS on user-supplied lists).
|
|
function isValidEmail(addr: string): boolean {
|
|
if (/\s/.test(addr)) return false;
|
|
const at = addr.indexOf('@');
|
|
if (at <= 0 || at !== addr.lastIndexOf('@')) return false;
|
|
const domain = addr.slice(at + 1);
|
|
const dot = domain.lastIndexOf('.');
|
|
return dot > 0 && dot < domain.length - 1;
|
|
}
|
|
|
|
// ─── API tokens (admin only — a token can never mint more tokens) ─────────────
|
|
|
|
router.get('/tokens', requireAdmin, async (_req: Request, res: Response) => {
|
|
const rows = await query(
|
|
`SELECT id, name, prefix, scope, created_by, last_used_at, expires_at, created_at
|
|
FROM api_tokens ORDER BY created_at DESC`);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.post('/tokens', requireAdmin, async (req: Request, res: Response) => {
|
|
const { name, scope, expires_days } = req.body as { name?: string; scope?: string; expires_days?: number };
|
|
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
|
|
if (scope !== 'read' && scope !== 'write') return res.status(400).json({ error: 'scope must be read or write' });
|
|
|
|
const token = `mtm_${randomBytes(24).toString('hex')}`;
|
|
const hash = createHash('sha256').update(token).digest('hex');
|
|
const expiresAt = expires_days && expires_days > 0
|
|
? new Date(Date.now() + expires_days * 86_400_000).toISOString()
|
|
: null;
|
|
|
|
const row = await queryOne<{ id: number }>(
|
|
`INSERT INTO api_tokens (name, token_hash, prefix, scope, created_by, expires_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
|
[name.trim().slice(0, 100), hash, token.slice(0, 12), scope, req.user?.username ?? null, expiresAt]);
|
|
|
|
// The full token is returned exactly once — only the hash is stored.
|
|
res.status(201).json({ id: row!.id, token });
|
|
});
|
|
|
|
router.delete('/tokens/:id', requireAdmin, async (req: Request, res: Response) => {
|
|
await query(`DELETE FROM api_tokens WHERE id = $1`, [parseInt(req.params.id, 10)]);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ─── Webhooks ──────────────────────────────────────────────────────────────────
|
|
|
|
router.get('/webhooks', async (_req: Request, res: Response) => {
|
|
const rows = await query(
|
|
`SELECT id, name, url, (secret IS NOT NULL AND secret != '') AS has_secret,
|
|
events, enabled, last_status, last_fired_at, created_at
|
|
FROM webhooks ORDER BY created_at DESC`);
|
|
res.json({ webhooks: rows, availableEvents: WEBHOOK_EVENTS });
|
|
});
|
|
|
|
router.post('/webhooks', requireWrite, async (req: Request, res: Response) => {
|
|
const { name, url, secret, events } = req.body as { name?: string; url?: string; secret?: string; events?: string[] };
|
|
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
|
|
if (!url || !/^https?:\/\//.test(url)) return res.status(400).json({ error: 'url must be http(s)' });
|
|
const evts = (Array.isArray(events) ? events : []).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e));
|
|
if (evts.length === 0) return res.status(400).json({ error: 'subscribe to at least one event' });
|
|
|
|
const row = await queryOne<{ id: number }>(
|
|
`INSERT INTO webhooks (name, url, secret, events) VALUES ($1,$2,$3,$4) RETURNING id`,
|
|
[name.trim().slice(0, 100), url.trim(), secret?.trim() || null, evts]);
|
|
res.status(201).json({ id: row!.id });
|
|
});
|
|
|
|
router.put('/webhooks/:id', requireWrite, async (req: Request, res: Response) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
const { name, url, secret, events, enabled } = req.body as {
|
|
name?: string; url?: string; secret?: string | null; events?: string[]; enabled?: boolean;
|
|
};
|
|
const existing = await queryOne<{ id: number }>(`SELECT id FROM webhooks WHERE id = $1`, [id]);
|
|
if (!existing) return res.status(404).json({ error: 'Webhook not found' });
|
|
|
|
if (url !== undefined && !/^https?:\/\//.test(url)) return res.status(400).json({ error: 'url must be http(s)' });
|
|
const evts = events !== undefined
|
|
? (Array.isArray(events) ? events : []).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e))
|
|
: undefined;
|
|
if (evts !== undefined && evts.length === 0) return res.status(400).json({ error: 'subscribe to at least one event' });
|
|
|
|
await query(
|
|
`UPDATE webhooks SET
|
|
name = COALESCE($2, name),
|
|
url = COALESCE($3, url),
|
|
secret = CASE WHEN $4::text IS NOT NULL THEN NULLIF($4, '') ELSE secret END,
|
|
events = COALESCE($5, events),
|
|
enabled = COALESCE($6, enabled)
|
|
WHERE id = $1`,
|
|
[id, name?.trim().slice(0, 100) ?? null, url?.trim() ?? null, secret ?? null, evts ?? null, enabled ?? null]);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.delete('/webhooks/:id', requireWrite, async (req: Request, res: Response) => {
|
|
await query(`DELETE FROM webhooks WHERE id = $1`, [parseInt(req.params.id, 10)]);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post('/webhooks/:id/test', requireWrite, async (req: Request, res: Response) => {
|
|
try {
|
|
const result = await webhookService.sendTest(parseInt(req.params.id, 10));
|
|
res.json({ ok: result.status >= 200 && result.status < 300, status: result.status });
|
|
} catch (e) {
|
|
res.status(400).json({ error: (e as Error).message });
|
|
}
|
|
});
|
|
|
|
// ─── Scheduled reports ─────────────────────────────────────────────────────────
|
|
|
|
router.get('/reports', async (_req: Request, res: Response) => {
|
|
const rows = await query(`SELECT * FROM report_schedules ORDER BY created_at DESC`);
|
|
res.json(rows);
|
|
});
|
|
|
|
router.post('/reports', requireWrite, async (req: Request, res: Response) => {
|
|
const { name, frequency, recipients } = req.body as { name?: string; frequency?: string; recipients?: string };
|
|
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
|
|
if (!['daily', 'weekly', 'monthly'].includes(frequency ?? '')) return res.status(400).json({ error: 'frequency must be daily, weekly or monthly' });
|
|
const rcpts = (recipients ?? '').split(',').map(r => r.trim()).filter(Boolean);
|
|
if (rcpts.length === 0 || rcpts.some(r => !isValidEmail(r))) {
|
|
return res.status(400).json({ error: 'recipients must be comma-separated email addresses' });
|
|
}
|
|
|
|
const row = await queryOne<{ id: number }>(
|
|
`INSERT INTO report_schedules (name, frequency, recipients, next_run_at)
|
|
VALUES ($1,$2,$3,$4) RETURNING id`,
|
|
[name.trim().slice(0, 100), frequency, rcpts.join(', '), computeNextRun(frequency!, new Date())]);
|
|
res.status(201).json({ id: row!.id });
|
|
});
|
|
|
|
router.put('/reports/:id', requireWrite, async (req: Request, res: Response) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
const { enabled } = req.body as { enabled?: boolean };
|
|
const result = await query(
|
|
`UPDATE report_schedules SET enabled = COALESCE($2, enabled) WHERE id = $1 RETURNING id`,
|
|
[id, enabled ?? null]);
|
|
if (!result.length) return res.status(404).json({ error: 'Report schedule not found' });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.delete('/reports/:id', requireWrite, async (req: Request, res: Response) => {
|
|
await query(`DELETE FROM report_schedules WHERE id = $1`, [parseInt(req.params.id, 10)]);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// POST /api/automation/reports/:id/send-now — immediate delivery (also validates SMTP)
|
|
router.post('/reports/:id/send-now', requireWrite, async (req: Request, res: Response) => {
|
|
const row = await queryOne<{ name: string; frequency: 'daily' | 'weekly' | 'monthly'; recipients: string }>(
|
|
`SELECT name, frequency, recipients FROM report_schedules WHERE id = $1`, [parseInt(req.params.id, 10)]);
|
|
if (!row) return res.status(404).json({ error: 'Report schedule not found' });
|
|
try {
|
|
await reportService.send(row);
|
|
await query(`UPDATE report_schedules SET last_sent_at = NOW() WHERE id = $1`, [parseInt(req.params.id, 10)]);
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(502).json({ error: (e as Error).message });
|
|
}
|
|
});
|
|
|
|
export default router;
|