changemaker.lite/api/src/services/listmonk.client.ts
bunker-admin 1bf19fff0e Security audit: fix 30 findings across auth, IDOR, XSS, path traversal, infrastructure
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
2026-03-27 08:47:24 -06:00

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();