1+ import type { Request , Response , NextFunction } from "express" ;
2+
3+ const BLOCK_WINDOW_MS = 15 * 60_000 ;
4+ const REPEAT_OFFENDER_THRESHOLD = 3 ;
5+
6+ const HIGH_RISK_PATTERNS = [
7+ {
8+ name : "instruction_override" ,
9+ pattern : / i g n o r e ( a l l | a n y | t h e | y o u r | p r e v i o u s | p r i o r ) ( i n s t r u c t i o n s ? | p r o m p t s ? | r u l e s ? ) / i,
10+ } ,
11+ {
12+ name : "instruction_override" ,
13+ pattern : / d i s r e g a r d ( a l l | a n y | t h e | y o u r | p r e v i o u s | p r i o r ) ( i n s t r u c t i o n s ? | p r o m p t s ? | r u l e s ? ) / i,
14+ } ,
15+ {
16+ name : "prompt_exfiltration" ,
17+ pattern : / r e v e a l ( t h e ) ? ( s y s t e m p r o m p t | h i d d e n p r o m p t | d e v e l o p e r m e s s a g e | i n t e r n a l i n s t r u c t i o n s ? ) / i,
18+ } ,
19+ {
20+ name : "prompt_exfiltration" ,
21+ pattern : / ( s y s t e m | d e v e l o p e r ) p r o m p t / i,
22+ } ,
23+ {
24+ name : "role_spoofing" ,
25+ pattern : / r o l e \s * : \s * ( s y s t e m | a s s i s t a n t | d e v e l o p e r ) / i,
26+ } ,
27+ {
28+ name : "guardrail_bypass" ,
29+ pattern : / j a i l b r e a k | p r o m p t i n j e c t i o n | b y p a s s ( g u a r d r a i l s ? | f i l t e r s ? | s a f e t y ) / i,
30+ } ,
31+ {
32+ name : "tooling_probe" ,
33+ pattern : / t o o l c a l l | f u n c t i o n c a l l | e x e c u t e c o d e | r u n c o m m a n d / i,
34+ } ,
35+ {
36+ name : "embedded_payload" ,
37+ pattern : / ` ` ` | < s c r i p t | < i f r a m e | d a t a : t e x t \/ h t m l | j a v a s c r i p t : / i,
38+ } ,
39+ ] as const ;
40+
41+ const SPAM_PATTERNS = [
42+ { name : "link_flood" , pattern : / ( h t t p s ? : \/ \/ | w w w \. ) / gi } ,
43+ { name : "symbol_spam" , pattern : / ( [ ! ? $ # * ] ) \1{ 5 , } / g } ,
44+ { name : "character_spam" , pattern : / ( .) \1{ 24 , } / g } ,
45+ ] as const ;
46+
47+ interface PromptAssessment {
48+ blocked : boolean ;
49+ normalized : string ;
50+ signals : string [ ] ;
51+ spamScore : number ;
52+ }
53+
54+ interface OffenderRecord {
55+ count : number ;
56+ firstBlockedAt : number ;
57+ lastBlockedAt : number ;
58+ }
59+
60+ const offenderRecords = new Map < string , OffenderRecord > ( ) ;
61+
62+ function normalize ( value : string ) : string {
63+ return value
64+ . replace ( / [ \u0000 - \u0008 \u000B \u000C \u000E - \u001F \u007F ] / g, " " )
65+ . replace ( / \s + / g, " " )
66+ . trim ( ) ;
67+ }
68+
69+ function countMatches ( value : string , pattern : RegExp ) : number {
70+ const flags = pattern . flags . includes ( "g" ) ? pattern . flags : `${ pattern . flags } g` ;
71+ return [ ...value . matchAll ( new RegExp ( pattern . source , flags ) ) ] . length ;
72+ }
73+
74+ function assessPrompt ( value : string ) : PromptAssessment {
75+ const normalized = normalize ( value ) ;
76+ if ( ! normalized ) {
77+ return {
78+ blocked : false ,
79+ normalized,
80+ signals : [ ] ,
81+ spamScore : 0 ,
82+ } ;
83+ }
84+
85+ const signals = HIGH_RISK_PATTERNS
86+ . filter ( ( { pattern } ) => pattern . test ( normalized ) )
87+ . map ( ( { name } ) => name ) ;
88+ const spamScore = SPAM_PATTERNS . reduce (
89+ ( sum , { pattern } ) => sum + countMatches ( normalized , pattern ) ,
90+ 0 ,
91+ ) ;
92+
93+ return {
94+ blocked : signals . length >= 1 || spamScore >= 3 ,
95+ normalized,
96+ signals,
97+ spamScore,
98+ } ;
99+ }
100+
101+ function updateOffenderRecord ( key : string ) : OffenderRecord {
102+ const now = Date . now ( ) ;
103+ const existing = offenderRecords . get ( key ) ;
104+
105+ if ( ! existing || now - existing . lastBlockedAt > BLOCK_WINDOW_MS ) {
106+ const fresh = {
107+ count : 1 ,
108+ firstBlockedAt : now ,
109+ lastBlockedAt : now ,
110+ } ;
111+ offenderRecords . set ( key , fresh ) ;
112+ return fresh ;
113+ }
114+
115+ const next = {
116+ count : existing . count + 1 ,
117+ firstBlockedAt : existing . firstBlockedAt ,
118+ lastBlockedAt : now ,
119+ } ;
120+ offenderRecords . set ( key , next ) ;
121+ return next ;
122+ }
123+
124+ function promptExcerpt ( value : string ) : string {
125+ return value . slice ( 0 , 120 ) ;
126+ }
127+
128+ function recordBlockedPrompt (
129+ req : Request ,
130+ field : string ,
131+ assessment : PromptAssessment ,
132+ ) : void {
133+ const route = req . originalUrl ?. split ( "?" ) [ 0 ] ?? req . path ;
134+ const record = updateOffenderRecord ( `${ req . ip } :${ route } ` ) ;
135+
136+ req . log . warn (
137+ {
138+ route,
139+ ip : req . ip ,
140+ field,
141+ promptLength : assessment . normalized . length ,
142+ promptExcerpt : promptExcerpt ( assessment . normalized ) ,
143+ signals : assessment . signals ,
144+ spamScore : assessment . spamScore ,
145+ repeatOffenderCount : record . count ,
146+ repeatOffender : record . count >= REPEAT_OFFENDER_THRESHOLD ,
147+ windowMs : BLOCK_WINDOW_MS ,
148+ } ,
149+ "Blocked unsafe AI prompt" ,
150+ ) ;
151+ }
152+
153+ export function blockUnsafePromptFields ( fields : string [ ] ) {
154+ return ( req : Request , res : Response , next : NextFunction ) : void => {
155+ const body = typeof req . body === "object" && req . body !== null ? req . body as Record < string , unknown > : { } ;
156+ for ( const field of fields ) {
157+ const raw = body [ field ] ;
158+ if ( typeof raw !== "string" ) continue ;
159+ const assessment = assessPrompt ( raw ) ;
160+ if ( assessment . blocked ) {
161+ recordBlockedPrompt ( req , field , assessment ) ;
162+ res . status ( 400 ) . json ( { error : `${ field } contains disallowed prompt content` } ) ;
163+ return ;
164+ }
165+ }
166+
167+ next ( ) ;
168+ } ;
169+ }
0 commit comments