Comprehensive 6-domain security audit addressing 8 Critical, 17 Important,
and 5 Low findings. Key fixes:
Critical:
- Strip PII from unauthenticated ticket lookup (IDOR)
- Add role+permission checks to event check-in routes
- Validate tier-to-event ownership on update/delete (IDOR)
- Fix path traversal in video replace (resolve + prefix check)
- Enable MongoDB authentication for Rocket.Chat
- Disable Grafana anonymous access
- Sanitize CSV exports against formula injection (payments)
- Apply DOMPurify to richDescription on public event page (XSS)
Important:
- Require current password for self-service password changes
- Atomic password reset token consumption (race condition fix)
- Scope postMessage to specific origin (not wildcard)
- Validate redirect parameter against open redirect
- Replace weak temp passwords (5760 values → crypto.randomBytes)
- Move shift capacity check inside transaction (TOCTOU fix)
- Fix EVENTS_ADMIN privilege inversion in ticketed events
- Make ENCRYPTION_KEY required (remove optional fallback)
- Add internal Prometheus metrics endpoint for Docker scraping
- Add nginx-level rate limiting (limit_req_zone)
- Fix X-Forwarded-For to use $remote_addr (prevents spoofing)
- Replace CSP stripping with frame-ancestors in embed proxies
- Remove error.message from Fastify 500 responses
- Strip PII from volunteer canvass address data
- Wrap GrapesJS output in {% raw %} to prevent Jinja2 SSTI
- Scope SSE token query param to /sse path only
- Sanitize Listmonk email query against injection
Bunker Admin
268 lines
7.6 KiB
TypeScript
268 lines
7.6 KiB
TypeScript
import { env } from '../config/env';
|
|
import { logger } from '../utils/logger';
|
|
|
|
// --- Types ---
|
|
|
|
export interface ListmonkList {
|
|
id: number;
|
|
uuid: string;
|
|
name: string;
|
|
type: 'public' | 'private' | 'temporary';
|
|
optin: 'single' | 'double';
|
|
tags: string[];
|
|
subscriber_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ListmonkSubscriber {
|
|
id: number;
|
|
uuid: string;
|
|
email: string;
|
|
name: string;
|
|
status: 'enabled' | 'disabled' | 'blocklisted';
|
|
lists: ListmonkList[];
|
|
attribs: Record<string, unknown>;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface BulkSyncResult {
|
|
total: number;
|
|
success: number;
|
|
failed: number;
|
|
errors: string[];
|
|
}
|
|
|
|
// --- Client ---
|
|
|
|
class ListmonkClient {
|
|
private get baseUrl(): string {
|
|
return env.LISTMONK_URL;
|
|
}
|
|
|
|
private get authHeader(): string {
|
|
return 'Basic ' + Buffer.from(`${env.LISTMONK_ADMIN_USER}:${env.LISTMONK_ADMIN_PASSWORD}`).toString('base64');
|
|
}
|
|
|
|
private get enabled(): boolean {
|
|
return env.LISTMONK_SYNC_ENABLED === 'true';
|
|
}
|
|
|
|
private assertEnabled(): void {
|
|
if (!this.enabled) {
|
|
throw new Error('Listmonk sync is disabled. Set LISTMONK_SYNC_ENABLED=true to enable.');
|
|
}
|
|
}
|
|
|
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const url = `${this.baseUrl}${path}`;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Authorization': this.authHeader,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`Listmonk API ${method} ${path} returned ${res.status}: ${text}`);
|
|
}
|
|
|
|
return await res.json() as T;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async checkHealth(): Promise<boolean> {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
try {
|
|
const res = await fetch(`${this.baseUrl}/api/health`, {
|
|
headers: { 'Authorization': this.authHeader },
|
|
signal: controller.signal,
|
|
});
|
|
return res.ok;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('Listmonk health check failed:', err instanceof Error ? err.message : err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async getLists(): Promise<ListmonkList[]> {
|
|
this.assertEnabled();
|
|
try {
|
|
const res = await this.request<{ data: { results: ListmonkList[] } }>('GET', '/api/lists?per_page=all');
|
|
return res.data.results || [];
|
|
} catch (err) {
|
|
logger.warn('Failed to fetch Listmonk lists:', err instanceof Error ? err.message : err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async createList(name: string, type: 'public' | 'private', tags: string[]): Promise<ListmonkList> {
|
|
this.assertEnabled();
|
|
try {
|
|
const res = await this.request<{ data: ListmonkList }>('POST', '/api/lists', {
|
|
name,
|
|
type,
|
|
optin: 'single',
|
|
tags,
|
|
});
|
|
return res.data;
|
|
} catch (err) {
|
|
logger.warn(`Failed to create Listmonk list "${name}":`, err instanceof Error ? err.message : err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async findSubscriberByEmail(email: string): Promise<ListmonkSubscriber | null> {
|
|
this.assertEnabled();
|
|
try {
|
|
// Validate email format and sanitize for Listmonk query language
|
|
// Strip all characters except valid email chars to prevent query injection
|
|
const safeEmail = email.replace(/[^a-zA-Z0-9@._+\-]/g, '').replace(/'/g, "''");
|
|
const query = encodeURIComponent(`subscribers.email='${safeEmail}'`);
|
|
const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>(
|
|
'GET',
|
|
`/api/subscribers?query=${query}&per_page=1`,
|
|
);
|
|
const results = res.data.results;
|
|
return results && results.length > 0 ? results[0] : null;
|
|
} catch (err) {
|
|
logger.warn(`Failed to find subscriber ${email}:`, err instanceof Error ? err.message : err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async createSubscriber(
|
|
email: string,
|
|
name: string,
|
|
listIds: number[],
|
|
attribs: Record<string, unknown>,
|
|
): Promise<ListmonkSubscriber> {
|
|
this.assertEnabled();
|
|
const res = await this.request<{ data: ListmonkSubscriber }>('POST', '/api/subscribers', {
|
|
email,
|
|
name,
|
|
status: 'enabled',
|
|
lists: listIds,
|
|
attribs,
|
|
});
|
|
return res.data;
|
|
}
|
|
|
|
async updateSubscriber(
|
|
id: number,
|
|
data: { email: string; name?: string; lists?: number[]; attribs?: Record<string, unknown> },
|
|
): Promise<ListmonkSubscriber> {
|
|
this.assertEnabled();
|
|
const res = await this.request<{ data: ListmonkSubscriber }>('PUT', `/api/subscribers/${id}`, data);
|
|
return res.data;
|
|
}
|
|
|
|
async updateList(
|
|
id: number,
|
|
data: { name?: string; tags?: string[] },
|
|
): Promise<ListmonkList> {
|
|
this.assertEnabled();
|
|
const res = await this.request<{ data: ListmonkList }>('PUT', `/api/lists/${id}`, data);
|
|
return res.data;
|
|
}
|
|
|
|
async deleteList(id: number): Promise<void> {
|
|
this.assertEnabled();
|
|
await this.request<unknown>('DELETE', `/api/lists/${id}`);
|
|
}
|
|
|
|
async removeSubscriberFromLists(
|
|
subscriberId: number,
|
|
listIdsToRemove: number[],
|
|
currentEmail: string,
|
|
currentListIds: number[],
|
|
): Promise<ListmonkSubscriber> {
|
|
this.assertEnabled();
|
|
const filteredIds = currentListIds.filter(id => !listIdsToRemove.includes(id));
|
|
return this.updateSubscriber(subscriberId, {
|
|
email: currentEmail,
|
|
lists: filteredIds,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get campaigns with stats (for dashboard)
|
|
*/
|
|
async getCampaigns(): Promise<Array<{
|
|
id: number;
|
|
name: string;
|
|
status: string;
|
|
sent: number;
|
|
views: number;
|
|
clicks: number;
|
|
started_at: string | null;
|
|
updated_at: string;
|
|
}>> {
|
|
try {
|
|
const res = await this.request<{ data: { results: Array<{
|
|
id: number;
|
|
name: string;
|
|
status: string;
|
|
stats: { sent: number; views: number; clicks: number };
|
|
started_at: string | null;
|
|
updated_at: string;
|
|
}> } }>('GET', '/api/campaigns?per_page=all&order_by=updated_at&order=desc');
|
|
return (res.data.results || []).map(c => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
status: c.status,
|
|
sent: c.stats?.sent || 0,
|
|
views: c.stats?.views || 0,
|
|
clicks: c.stats?.clicks || 0,
|
|
started_at: c.started_at,
|
|
updated_at: c.updated_at,
|
|
}));
|
|
} catch (err) {
|
|
logger.warn('Failed to fetch Listmonk campaigns:', err instanceof Error ? err.message : err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async upsertSubscriber(
|
|
email: string,
|
|
name: string,
|
|
listIds: number[],
|
|
attribs: Record<string, unknown>,
|
|
): Promise<ListmonkSubscriber> {
|
|
this.assertEnabled();
|
|
const existing = await this.findSubscriberByEmail(email);
|
|
if (existing) {
|
|
// Merge lists: combine existing + new, deduplicate
|
|
const existingListIds = existing.lists.map(l => l.id);
|
|
const mergedListIds = [...new Set([...existingListIds, ...listIds])];
|
|
// Merge attribs
|
|
const mergedAttribs = { ...existing.attribs, ...attribs };
|
|
return this.updateSubscriber(existing.id, {
|
|
email,
|
|
name: name || existing.name,
|
|
lists: mergedListIds,
|
|
attribs: mergedAttribs,
|
|
});
|
|
}
|
|
return this.createSubscriber(email, name, listIds, attribs);
|
|
}
|
|
}
|
|
|
|
export const listmonkClient = new ListmonkClient();
|