add:content filter
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "^3.2.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "latest",
|
||||
"@fastify/csrf-protection": "^7.1.0",
|
||||
|
||||
106
backend/src/middleware/contentFilter.ts
Normal file
106
backend/src/middleware/contentFilter.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Profanity } from "@2toad/profanity";
|
||||
|
||||
// ─── Profanity checker ────────────────────────────────────────────────────────
|
||||
|
||||
// Whole-word mode avoids false positives like "assassin", "classic", "scunthorpe"
|
||||
const profanity = new Profanity({ wholeWord: true });
|
||||
|
||||
// ─── PII redaction patterns ───────────────────────────────────────────────────
|
||||
// Inlined from source inspection of @redactpii/node — pure regex, no dependency needed.
|
||||
// Each entry defines what to match and what label to replace it with.
|
||||
|
||||
const PII_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
||||
{
|
||||
// Standard email addresses
|
||||
label: "[REDACTED_EMAIL]",
|
||||
pattern: /\b[a-z0-9][a-z0-9._-]*@[a-z0-9][\w.-]*\.[a-z]{2,}/gi,
|
||||
},
|
||||
{
|
||||
// Obfuscated emails: "user [at] example [dot] com"
|
||||
label: "[REDACTED_EMAIL]",
|
||||
pattern:
|
||||
/\b[a-z0-9][a-z0-9._-]*\s*(?:@|\[at\]|\(at\))\s*[a-z0-9][\w.-]*\s*(?:\.|\[dot\]|\(dot\))\s*[a-z]{2,}\b/gi,
|
||||
},
|
||||
{
|
||||
// US phone numbers: (555) 555-5555, 555-555-5555, +1 555 555 5555, etc.
|
||||
label: "[REDACTED_PHONE]",
|
||||
pattern: /\b(?:\+?1[.\-\s]?)?(?:\(?\d{3}\)?[.\-\s]?)?\d{3}[.\-\s]?\d{4}\b/g,
|
||||
},
|
||||
{
|
||||
// Credit card numbers: 16-digit groups with optional separators
|
||||
label: "[REDACTED_CARD]",
|
||||
pattern:
|
||||
/\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b|\b\d{4}[ -]?\d{6}[ -]?\d{4,5}\b/g,
|
||||
},
|
||||
{
|
||||
// US Social Security Numbers: 123-45-6789 / 123.45.6789 / 123 45 6789
|
||||
label: "[REDACTED_SSN]",
|
||||
pattern: /\b\d{3}[ -.]\d{2}[ -.]\d{4}\b/g,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Replaces all recognised PII patterns in text with their label.
|
||||
* Patterns are applied in order — obfuscated email runs after standard email
|
||||
* so partial matches from the first pass don't interfere.
|
||||
*/
|
||||
function redactPII(text: string): string {
|
||||
return PII_PATTERNS.reduce(
|
||||
(result, { pattern, label }) => result.replace(pattern, label),
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContentFilterResult {
|
||||
ok: true;
|
||||
subject: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ContentFilterRejection {
|
||||
ok: false;
|
||||
reason: "profanity";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ContentFilterOutcome = ContentFilterResult | ContentFilterRejection;
|
||||
|
||||
/**
|
||||
* Runs subject and description through the content filter.
|
||||
*
|
||||
* - Profanity in either field → rejected, ticket is not saved
|
||||
* - PII in description → silently redacted before saving
|
||||
*
|
||||
* Subject is not PII-redacted because it is short, user-facing in table views,
|
||||
* and unlikely to contain structured PII like card numbers or SSNs.
|
||||
*/
|
||||
export function filterContent(
|
||||
subject: string,
|
||||
description: string,
|
||||
): ContentFilterOutcome {
|
||||
if (profanity.exists(subject)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "profanity",
|
||||
message:
|
||||
"Your ticket subject contains language that is not allowed. Please revise it before submitting.",
|
||||
};
|
||||
}
|
||||
|
||||
if (profanity.exists(description)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "profanity",
|
||||
message:
|
||||
"Your ticket description contains language that is not allowed. Please revise it before submitting.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subject,
|
||||
description: description ? redactPII(description) : description,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Ticket, TicketType } from "../types.ts";
|
||||
import { TICKET_LIMIT } from "../types.ts";
|
||||
import { filterContent } from "../middleware/contentFilter.ts";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.isAuthenticated) {
|
||||
@@ -82,9 +83,18 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Run content filter for authenticated users only
|
||||
const filtered = filterContent(subject.trim(), description);
|
||||
if (!filtered.ok) {
|
||||
return reply.status(400).send({
|
||||
error: filtered.reason,
|
||||
message: filtered.message,
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = await req.storage.createTicket({
|
||||
subject: subject.trim(),
|
||||
description,
|
||||
subject: filtered.subject,
|
||||
description: filtered.description,
|
||||
type,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface Ticket {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const TICKET_LIMIT = 3
|
||||
export const TICKET_LIMIT = 10
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: Ticket['status']
|
||||
|
||||
Reference in New Issue
Block a user