FRE-4520: Fix security vulnerabilities in notification template system

- Fix HTML injection vulnerability with proper entity encoding
- Fix rate limit cleanup bug (count vs timestamp confusion)
- Add URL validation to prevent open redirect attacks
- Add expiration to in-memory deduplication entries
- Use Zod schema for config validation
- Add email format validation

All 29 tests passing. Ready for Code Reviewer final review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-01 19:35:22 -04:00
parent 2a5c6f49a7
commit c490735ba2
5 changed files with 161 additions and 48 deletions

View File

@@ -10,6 +10,7 @@ import { AllDefaultTemplates, DEFAULT_LOCALE } from '../templates/default-templa
const CACHE_TTL_MS = 300000;
const VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
const TRUSTED_DOMAINS = ['shieldai.com', 'app.shieldai.com', 'api.shieldai.com'];
export class TemplateService {
private static instance: TemplateService;
@@ -165,19 +166,54 @@ export class TemplateService {
varMap.set(v.name, v);
}
return text.replace(VARIABLE_PATTERN, (match, varName) => {
const isHtmlContext = text.includes('<') && text.includes('>');
return text.replace(VARIABLE_PATTERN, (match, varName) => {
const value = variables[varName];
if (value !== undefined) {
return String(value);
const stringValue = String(value);
if (this.isUrlVariable(varName)) {
return this.validateUrl(stringValue);
}
return isHtmlContext ? this.escapeHtml(stringValue) : stringValue;
}
const schemaVar = varMap.get(varName);
if (schemaVar?.defaultValue !== undefined) {
return schemaVar.defaultValue;
const defaultValue = String(schemaVar.defaultValue);
if (this.isUrlVariable(varName)) {
return this.validateUrl(defaultValue);
}
return isHtmlContext ? this.escapeHtml(defaultValue) : defaultValue;
}
return match;
});
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
private validateUrl(url: string): string {
try {
const parsed = new URL(url);
if (TRUSTED_DOMAINS.includes(parsed.hostname)) {
return url;
}
return '/';
} catch {
return '/';
}
}
private isUrlVariable(varName: string): boolean {
return varName.endsWith('_url');
}
private getCached(templateId: string, locale: string): TemplateDefinition | null {
const cacheKey = this.getCacheKey(templateId, locale);
const entry = this.cache.get(cacheKey);