palaryn 0.3.7 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +2 -1
  2. package/dist/src/auth/routes.d.ts.map +1 -1
  3. package/dist/src/auth/routes.js +5 -1
  4. package/dist/src/auth/routes.js.map +1 -1
  5. package/dist/src/config/defaults.d.ts.map +1 -1
  6. package/dist/src/config/defaults.js +7 -2
  7. package/dist/src/config/defaults.js.map +1 -1
  8. package/dist/src/dlp/composite-scanner.d.ts.map +1 -1
  9. package/dist/src/dlp/composite-scanner.js +26 -1
  10. package/dist/src/dlp/composite-scanner.js.map +1 -1
  11. package/dist/src/dlp/heuristic-scorer.d.ts +31 -0
  12. package/dist/src/dlp/heuristic-scorer.d.ts.map +1 -0
  13. package/dist/src/dlp/heuristic-scorer.js +314 -0
  14. package/dist/src/dlp/heuristic-scorer.js.map +1 -0
  15. package/dist/src/dlp/llm-classifier.d.ts +38 -0
  16. package/dist/src/dlp/llm-classifier.d.ts.map +1 -0
  17. package/dist/src/dlp/llm-classifier.js +152 -0
  18. package/dist/src/dlp/llm-classifier.js.map +1 -0
  19. package/dist/src/dlp/patterns.d.ts.map +1 -1
  20. package/dist/src/dlp/patterns.js +1 -0
  21. package/dist/src/dlp/patterns.js.map +1 -1
  22. package/dist/src/dlp/prompt-injection-backend.d.ts.map +1 -1
  23. package/dist/src/dlp/prompt-injection-backend.js +17 -0
  24. package/dist/src/dlp/prompt-injection-backend.js.map +1 -1
  25. package/dist/src/dlp/prompt-injection-patterns.d.ts.map +1 -1
  26. package/dist/src/dlp/prompt-injection-patterns.js +36 -0
  27. package/dist/src/dlp/prompt-injection-patterns.js.map +1 -1
  28. package/dist/src/dlp/regex-backend.d.ts.map +1 -1
  29. package/dist/src/dlp/regex-backend.js +2 -38
  30. package/dist/src/dlp/regex-backend.js.map +1 -1
  31. package/dist/src/dlp/scanner.d.ts.map +1 -1
  32. package/dist/src/dlp/scanner.js +38 -6
  33. package/dist/src/dlp/scanner.js.map +1 -1
  34. package/dist/src/dlp/text-normalizer.d.ts +10 -1
  35. package/dist/src/dlp/text-normalizer.d.ts.map +1 -1
  36. package/dist/src/dlp/text-normalizer.js +124 -2
  37. package/dist/src/dlp/text-normalizer.js.map +1 -1
  38. package/dist/src/mcp/http-transport.d.ts +2 -0
  39. package/dist/src/mcp/http-transport.d.ts.map +1 -1
  40. package/dist/src/mcp/http-transport.js +25 -6
  41. package/dist/src/mcp/http-transport.js.map +1 -1
  42. package/dist/src/policy/engine.d.ts.map +1 -1
  43. package/dist/src/policy/engine.js +109 -0
  44. package/dist/src/policy/engine.js.map +1 -1
  45. package/dist/src/saas/routes.d.ts.map +1 -1
  46. package/dist/src/saas/routes.js +19 -5
  47. package/dist/src/saas/routes.js.map +1 -1
  48. package/dist/src/server/app.d.ts.map +1 -1
  49. package/dist/src/server/app.js +7 -0
  50. package/dist/src/server/app.js.map +1 -1
  51. package/dist/src/server/gateway.d.ts +1 -0
  52. package/dist/src/server/gateway.d.ts.map +1 -1
  53. package/dist/src/server/gateway.js +160 -1
  54. package/dist/src/server/gateway.js.map +1 -1
  55. package/dist/src/types/config.d.ts +14 -1
  56. package/dist/src/types/config.d.ts.map +1 -1
  57. package/dist/tests/security/pentest-payloads.d.ts +46 -0
  58. package/dist/tests/security/pentest-payloads.d.ts.map +1 -0
  59. package/dist/tests/security/pentest-payloads.js +475 -0
  60. package/dist/tests/security/pentest-payloads.js.map +1 -0
  61. package/dist/tests/unit/adversarial-pipeline.test.d.ts +15 -0
  62. package/dist/tests/unit/adversarial-pipeline.test.d.ts.map +1 -0
  63. package/dist/tests/unit/adversarial-pipeline.test.js +1557 -0
  64. package/dist/tests/unit/adversarial-pipeline.test.js.map +1 -0
  65. package/dist/tests/unit/dlp-scanner.test.js +5 -5
  66. package/dist/tests/unit/gateway-branches.test.js +137 -0
  67. package/dist/tests/unit/gateway-branches.test.js.map +1 -1
  68. package/dist/tests/unit/heuristic-scorer.test.d.ts +2 -0
  69. package/dist/tests/unit/heuristic-scorer.test.d.ts.map +1 -0
  70. package/dist/tests/unit/heuristic-scorer.test.js +248 -0
  71. package/dist/tests/unit/heuristic-scorer.test.js.map +1 -0
  72. package/dist/tests/unit/llm-classifier.test.d.ts +2 -0
  73. package/dist/tests/unit/llm-classifier.test.d.ts.map +1 -0
  74. package/dist/tests/unit/llm-classifier.test.js +349 -0
  75. package/dist/tests/unit/llm-classifier.test.js.map +1 -0
  76. package/dist/tests/unit/prompt-injection-backend.test.js +122 -0
  77. package/dist/tests/unit/prompt-injection-backend.test.js.map +1 -1
  78. package/dist/tests/unit/text-normalizer.test.js +52 -1
  79. package/dist/tests/unit/text-normalizer.test.js.map +1 -1
  80. package/package.json +1 -1
  81. package/policy-packs/default.yaml +88 -0
  82. package/src/auth/routes.ts +6 -1
  83. package/src/config/defaults.ts +7 -2
  84. package/src/dlp/composite-scanner.ts +27 -1
  85. package/src/dlp/heuristic-scorer.ts +342 -0
  86. package/src/dlp/llm-classifier.ts +191 -0
  87. package/src/dlp/patterns.ts +1 -0
  88. package/src/dlp/prompt-injection-backend.ts +19 -1
  89. package/src/dlp/prompt-injection-patterns.ts +38 -0
  90. package/src/dlp/regex-backend.ts +2 -45
  91. package/src/dlp/scanner.ts +36 -6
  92. package/src/dlp/text-normalizer.ts +130 -2
  93. package/src/mcp/http-transport.ts +29 -6
  94. package/src/policy/engine.ts +102 -0
  95. package/src/saas/routes.ts +22 -5
  96. package/src/server/app.ts +7 -0
  97. package/src/server/gateway.ts +196 -1
  98. package/src/types/config.ts +15 -1
@@ -1,5 +1,6 @@
1
1
  import { DLPBackend, DLPDetection } from './interfaces';
2
2
  import { DLPPattern, SECRET_PATTERNS, PII_PATTERNS } from './patterns';
3
+ import { normalizeText } from './text-normalizer';
3
4
 
4
5
  export interface RegexBackendConfig {
5
6
  /** Enable secret pattern detection. Default true. */
@@ -8,31 +9,6 @@ export interface RegexBackendConfig {
8
9
  pii_detection?: boolean;
9
10
  }
10
11
 
11
- /**
12
- * Zero-width and invisible Unicode characters that can be used to evade
13
- * regex-based secret detection (e.g. embedding \u200b inside "AKIA...").
14
- */
15
- const ZERO_WIDTH_RE = /[\u200B\u200C\u200D\u200E\u200F\uFEFF\u00AD\u034F\u061C\u180E\u2060\u2061\u2062\u2063\u2064\u2066\u2067\u2068\u2069\u206A\u206B\u206C\u206D\u206E\u206F]/g;
16
-
17
- /**
18
- * Common Unicode homoglyphs that look like ASCII characters but would bypass
19
- * regex patterns designed for ASCII. Maps Cyrillic/Greek/other lookalikes to
20
- * their ASCII equivalents.
21
- */
22
- const HOMOGLYPH_MAP: Record<string, string> = {
23
- '\u0410': 'A', '\u0412': 'B', '\u0421': 'C', '\u0415': 'E', '\u041D': 'H',
24
- '\u041A': 'K', '\u041C': 'M', '\u041E': 'O', '\u0420': 'P', '\u0422': 'T',
25
- '\u0425': 'X', '\u0430': 'a', '\u0435': 'e', '\u043E': 'o', '\u0440': 'p',
26
- '\u0441': 'c', '\u0443': 'y', '\u0445': 'x', '\u04BB': 'h',
27
- '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u0397': 'H', '\u0399': 'I',
28
- '\u039A': 'K', '\u039C': 'M', '\u039D': 'N', '\u039F': 'O', '\u03A1': 'P',
29
- '\u03A4': 'T', '\u03A7': 'X', '\u03B1': 'a', '\u03BF': 'o',
30
- '\u2010': '-', '\u2011': '-', '\u2012': '-', '\u2013': '-', '\u2014': '-',
31
- '\uFF21': 'A', '\uFF22': 'B', '\uFF23': 'C', '\uFF24': 'D', '\uFF25': 'E',
32
- };
33
-
34
- const HOMOGLYPH_RE = new RegExp('[' + Object.keys(HOMOGLYPH_MAP).join('') + ']', 'g');
35
-
36
12
  /**
37
13
  * Regex to detect potential Base64-encoded strings.
38
14
  * Matches strings of at least 20 characters using the Base64 alphabet,
@@ -68,25 +44,6 @@ function decodeBase64Content(value: string): string {
68
44
  return decoded.join('\n');
69
45
  }
70
46
 
71
- /**
72
- * Normalize input to defeat common DLP evasion techniques:
73
- * 1. NFC Unicode normalization (canonical decomposition + composition)
74
- * 2. Strip zero-width / invisible characters
75
- * 3. Replace common Unicode homoglyphs with ASCII equivalents
76
- */
77
- function normalizeForDLP(value: string): string {
78
- // Step 1: NFC normalization
79
- let normalized = value.normalize('NFC');
80
-
81
- // Step 2: Strip zero-width characters
82
- normalized = normalized.replace(ZERO_WIDTH_RE, '');
83
-
84
- // Step 3: Replace homoglyphs with ASCII equivalents
85
- normalized = normalized.replace(HOMOGLYPH_RE, (ch) => HOMOGLYPH_MAP[ch] || ch);
86
-
87
- return normalized;
88
- }
89
-
90
47
  /**
91
48
  * Regex-based DLP backend that uses the same patterns as the built-in DLPScanner.
92
49
  *
@@ -115,7 +72,7 @@ export class RegexDLPBackend implements DLPBackend {
115
72
  * testing to avoid state leaking between calls.
116
73
  */
117
74
  scanString(value: string): DLPDetection[] {
118
- const normalized = normalizeForDLP(value);
75
+ const normalized = normalizeText(value);
119
76
  const detections: DLPDetection[] = [];
120
77
 
121
78
  if (this.secretsEnabled) {
@@ -2,9 +2,10 @@ import { createHash } from 'crypto';
2
2
  import { DLPReport, DLPRedaction, RedactionMethod, DLPSeverity } from '../types/tool-result';
3
3
  import { DLPConfig } from '../types/config';
4
4
  import { SECRET_PATTERNS, PII_PATTERNS } from './patterns';
5
+ import { normalizeText } from './text-normalizer';
5
6
 
6
7
  /** Maximum recursion depth to guard against circular or deeply nested structures. */
7
- const MAX_SCAN_DEPTH = 32;
8
+ const MAX_SCAN_DEPTH = 64;
8
9
 
9
10
  /**
10
11
  * DLPScanner detects secrets and PII in tool call arguments and outputs,
@@ -60,15 +61,16 @@ export class DLPScanner {
60
61
  private scanString(value: string, path: string): { detected: string[]; redactions: DLPRedaction[] } {
61
62
  const detected: string[] = [];
62
63
  const redactions: DLPRedaction[] = [];
64
+ const normalized = normalizeText(value);
65
+ const useNormalized = normalized !== value;
63
66
 
64
67
  if (this.config.secrets_detection) {
65
68
  for (const pattern of SECRET_PATTERNS) {
66
- // Reset before use to ensure a clean match
67
69
  pattern.pattern.lastIndex = 0;
68
70
  if (pattern.pattern.test(value)) {
69
- // Extract the first match for masked preview
70
71
  pattern.pattern.lastIndex = 0;
71
72
  const match = pattern.pattern.exec(value);
73
+ pattern.pattern.lastIndex = 0;
72
74
  detected.push(pattern.name);
73
75
  redactions.push({
74
76
  path,
@@ -76,9 +78,22 @@ export class DLPScanner {
76
78
  original_type: pattern.name,
77
79
  masked_preview: match ? DLPScanner.maskValue(match[0]) : undefined,
78
80
  });
81
+ } else if (useNormalized) {
82
+ pattern.pattern.lastIndex = 0;
83
+ if (pattern.pattern.test(normalized)) {
84
+ pattern.pattern.lastIndex = 0;
85
+ const match = pattern.pattern.exec(normalized);
86
+ pattern.pattern.lastIndex = 0;
87
+ detected.push(pattern.name);
88
+ redactions.push({
89
+ path,
90
+ method: this.config.default_redaction_method,
91
+ original_type: pattern.name,
92
+ masked_preview: match ? DLPScanner.maskValue(match[0]) : undefined,
93
+ });
94
+ }
95
+ pattern.pattern.lastIndex = 0;
79
96
  }
80
- // Reset after use so the next invocation starts fresh
81
- pattern.pattern.lastIndex = 0;
82
97
  }
83
98
  }
84
99
 
@@ -88,6 +103,7 @@ export class DLPScanner {
88
103
  if (pattern.pattern.test(value)) {
89
104
  pattern.pattern.lastIndex = 0;
90
105
  const match = pattern.pattern.exec(value);
106
+ pattern.pattern.lastIndex = 0;
91
107
  detected.push(pattern.name);
92
108
  redactions.push({
93
109
  path,
@@ -95,8 +111,22 @@ export class DLPScanner {
95
111
  original_type: pattern.name,
96
112
  masked_preview: match ? DLPScanner.maskValue(match[0]) : undefined,
97
113
  });
114
+ } else if (useNormalized) {
115
+ pattern.pattern.lastIndex = 0;
116
+ if (pattern.pattern.test(normalized)) {
117
+ pattern.pattern.lastIndex = 0;
118
+ const match = pattern.pattern.exec(normalized);
119
+ pattern.pattern.lastIndex = 0;
120
+ detected.push(pattern.name);
121
+ redactions.push({
122
+ path,
123
+ method: this.config.default_redaction_method,
124
+ original_type: pattern.name,
125
+ masked_preview: match ? DLPScanner.maskValue(match[0]) : undefined,
126
+ });
127
+ }
128
+ pattern.pattern.lastIndex = 0;
98
129
  }
99
- pattern.pattern.lastIndex = 0;
100
130
  }
101
131
  }
102
132
 
@@ -10,8 +10,12 @@
10
10
  // Zero-width character stripping
11
11
  // ---------------------------------------------------------------------------
12
12
 
13
- /** Regex matching zero-width and invisible Unicode characters. */
14
- export const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\u00AD\uFEFF\u200E\u200F\u2060\u2061\u2062\u2063\u2064\u180E]/g;
13
+ /** Regex matching zero-width and invisible Unicode characters.
14
+ * Comprehensive list covering: zero-width spaces/joiners, soft hyphen, BOM,
15
+ * directional marks/isolates, word joiners, invisible operators,
16
+ * combining grapheme joiner, Arabic letter mark, and deprecated formatting chars.
17
+ */
18
+ export const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\u00AD\uFEFF\u200E\u200F\u034F\u061C\u180E\u2060\u2061\u2062\u2063\u2064\u2066\u2067\u2068\u2069\u206A\u206B\u206C\u206D\u206E\u206F]/g;
15
19
 
16
20
  // ---------------------------------------------------------------------------
17
21
  // Homoglyph map (visually similar characters -> ASCII equivalents)
@@ -75,6 +79,28 @@ export const HOMOGLYPH_MAP: Record<string, string> = {
75
79
  '\u0131': 'i', // ı (dotless i)
76
80
  '\u0237': 'j', // ȷ (dotless j)
77
81
  '\u01C0': 'l', // ǀ (dental click -> l)
82
+ // Arabic-Indic digits -> ASCII
83
+ '\u0660': '0',
84
+ '\u0661': '1',
85
+ '\u0662': '2',
86
+ '\u0663': '3',
87
+ '\u0664': '4',
88
+ '\u0665': '5',
89
+ '\u0666': '6',
90
+ '\u0667': '7',
91
+ '\u0668': '8',
92
+ '\u0669': '9',
93
+ // Fullwidth digits -> ASCII (belt-and-suspenders with NFKC)
94
+ '\uFF10': '0',
95
+ '\uFF11': '1',
96
+ '\uFF12': '2',
97
+ '\uFF13': '3',
98
+ '\uFF14': '4',
99
+ '\uFF15': '5',
100
+ '\uFF16': '6',
101
+ '\uFF17': '7',
102
+ '\uFF18': '8',
103
+ '\uFF19': '9',
78
104
  };
79
105
 
80
106
  // Build reverse lookup for efficiency
@@ -164,6 +190,45 @@ function decodeURLEncoding(input: string): string {
164
190
  }
165
191
  }
166
192
 
193
+ // ---------------------------------------------------------------------------
194
+ // Encoded-payload decoders
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /** Decode \\xNN hex escapes to characters (printable ASCII only). */
198
+ function decodeHexEscapes(input: string): string {
199
+ return input.replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) => {
200
+ const code = parseInt(hex, 16);
201
+ return code >= 0x20 && code <= 0x7e ? String.fromCharCode(code) : _m;
202
+ });
203
+ }
204
+
205
+ /** Decode \\uNNNN unicode escapes to characters. */
206
+ function decodeUnicodeEscapes(input: string): string {
207
+ return input.replace(/\\u([0-9a-fA-F]{4})/g, (_m, hex) => {
208
+ const code = parseInt(hex, 16);
209
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _m;
210
+ });
211
+ }
212
+
213
+ /** Decode String.fromCharCode(...) expressions to the resulting string. */
214
+ function decodeStringFromCharCode(input: string): string {
215
+ return input.replace(/String\.fromCharCode\s*\(([0-9,\s]+)\)/gi, (_m, nums) => {
216
+ try {
217
+ const codes = nums.split(',').map((n: string) => parseInt(n.trim(), 10));
218
+ if (codes.some((c: number) => isNaN(c) || c < 0 || c > 0x10ffff)) return _m;
219
+ return String.fromCodePoint(...codes);
220
+ } catch {
221
+ return _m;
222
+ }
223
+ });
224
+ }
225
+
226
+ /** Strip emoji characters inserted between letters (e.g., i🔥g🔥n🔥o🔥r🔥e → ignore). */
227
+ function stripEmojisBetweenLetters(input: string): string {
228
+ // \p{So} = Symbol, Other (includes most emoji); only strip when between letters
229
+ return input.replace(/(\p{L})\p{So}+(?=\p{L})/gu, '$1');
230
+ }
231
+
167
232
  // ---------------------------------------------------------------------------
168
233
  // Main normalizer
169
234
  // ---------------------------------------------------------------------------
@@ -194,6 +259,14 @@ export function normalizeText(input: string): string {
194
259
  // 2. NFKC normalization (fullwidth -> ASCII, ligatures -> components, etc.)
195
260
  text = text.normalize('NFKC');
196
261
 
262
+ // 2.5 Decode hex/unicode/fromCharCode escapes
263
+ text = decodeHexEscapes(text);
264
+ text = decodeUnicodeEscapes(text);
265
+ text = decodeStringFromCharCode(text);
266
+
267
+ // 2.7 Strip emoji between letters
268
+ text = stripEmojisBetweenLetters(text);
269
+
197
270
  // 3. Decode HTML entities
198
271
  text = decodeHTMLEntities(text);
199
272
 
@@ -223,3 +296,58 @@ export function normalizeText(input: string): string {
223
296
  export function normalizeLeetspeak(normalizedInput: string): string {
224
297
  return normalizedInput.replace(leetspeakRegex, (ch) => LEETSPEAK_MAP[ch] || ch);
225
298
  }
299
+
300
+ /**
301
+ * Decode encoded payloads found in text (atob, data URIs).
302
+ * Returns an array of decoded strings (printable ASCII only).
303
+ */
304
+ export function decodeEncodedPayloads(input: string): string[] {
305
+ const results: string[] = [];
306
+
307
+ // Match atob("...") or atob('...')
308
+ const atobRegex = /atob\s*\(\s*["']([A-Za-z0-9+/=]+)["']\s*\)/g;
309
+ let m: RegExpExecArray | null;
310
+ while ((m = atobRegex.exec(input)) !== null) {
311
+ try {
312
+ const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
313
+ // Only include if it's mostly printable ASCII
314
+ if (/^[\x20-\x7e\s]+$/.test(decoded)) {
315
+ results.push(decoded);
316
+ }
317
+ } catch { /* ignore decode failures */ }
318
+ }
319
+
320
+ // Match data:...;base64,DATA
321
+ const dataUriRegex = /data:[^;]*;base64,([A-Za-z0-9+/=]+)/g;
322
+ while ((m = dataUriRegex.exec(input)) !== null) {
323
+ try {
324
+ const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
325
+ if (/^[\x20-\x7e\s]+$/.test(decoded)) {
326
+ results.push(decoded);
327
+ }
328
+ } catch { /* ignore decode failures */ }
329
+ }
330
+
331
+ // Bare base64 strings (min 20 chars, word-boundary-isolated)
332
+ // Skip regions already matched by atob() or data: URI patterns above.
333
+ const bareBase64Regex = /(?<![A-Za-z0-9+/=])([A-Za-z0-9+/]{20,}={0,2})(?![A-Za-z0-9+/=])/g;
334
+ while ((m = bareBase64Regex.exec(input)) !== null) {
335
+ // Skip if this is part of an atob("...") or data:;base64, context
336
+ const prefix = input.slice(Math.max(0, m.index - 10), m.index);
337
+ if (/atob\s*\(\s*["']$/i.test(prefix) || /base64,$/i.test(prefix)) {
338
+ continue;
339
+ }
340
+ try {
341
+ const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
342
+ // Only include if decoded text is mostly printable ASCII (>=80%) and >=10 chars
343
+ if (decoded.length >= 10) {
344
+ const printableCount = (decoded.match(/[\x20-\x7e]/g) || []).length;
345
+ if (printableCount / decoded.length >= 0.8) {
346
+ results.push(decoded);
347
+ }
348
+ }
349
+ } catch { /* ignore decode failures */ }
350
+ }
351
+
352
+ return results;
353
+ }
@@ -17,20 +17,37 @@ export interface MCPHttpConfig {
17
17
  platform?: string;
18
18
  /** Gateway's own base URL — when set, self-referencing requests get the OAuth token injected */
19
19
  gateway_base_url?: string;
20
+ /** Local port the app listens on — used to rewrite self-referencing URLs to localhost */
21
+ local_port?: number;
20
22
  }
21
23
 
22
24
  interface ResolvedMCPHttpConfig {
23
25
  platform: string;
24
26
  gateway_base_url?: string;
27
+ local_port?: number;
25
28
  }
26
29
 
27
30
  function resolveConfig(config?: MCPHttpConfig): ResolvedMCPHttpConfig {
28
31
  return {
29
32
  platform: config?.platform || 'mcp_http',
30
33
  gateway_base_url: config?.gateway_base_url,
34
+ local_port: config?.local_port,
31
35
  };
32
36
  }
33
37
 
38
+ /**
39
+ * Rewrite self-referencing URLs to localhost to avoid circular requests
40
+ * through the reverse proxy (Caddy). E.g.:
41
+ * https://app.palaryn.com/auth/register → http://localhost:3000/auth/register
42
+ */
43
+ function rewriteSelfUrl(config: ResolvedMCPHttpConfig, url: string): string {
44
+ if (config.gateway_base_url && config.local_port && url.startsWith(config.gateway_base_url)) {
45
+ const path = url.slice(config.gateway_base_url.length);
46
+ return `http://localhost:${config.local_port}${path}`;
47
+ }
48
+ return url;
49
+ }
50
+
34
51
  // ---------------------------------------------------------------------------
35
52
  // Auth identity resolution
36
53
  // ---------------------------------------------------------------------------
@@ -403,12 +420,14 @@ function createMCPServerInstance(gateway: Gateway, config: ResolvedMCPHttpConfig
403
420
  if (capError) return permissionDeniedError(capError);
404
421
 
405
422
  const { constraints, context } = extractCommon(a);
423
+ const originalUrl = a.url as string;
424
+ const resolvedUrl = rewriteSelfUrl(config, originalUrl);
406
425
  const toolCall = buildToolCall(config, {
407
426
  toolName: 'http.request',
408
427
  capability,
409
428
  args: {
410
429
  method,
411
- url: a.url as string,
430
+ url: resolvedUrl,
412
431
  headers: a.headers as Record<string, string> | undefined,
413
432
  body: typeof a.body === 'string' ? parseBody(a.body) : undefined,
414
433
  },
@@ -416,7 +435,7 @@ function createMCPServerInstance(gateway: Gateway, config: ResolvedMCPHttpConfig
416
435
  context,
417
436
  identity,
418
437
  });
419
- setInternalAuth(config, identity, toolCall, a.url as string);
438
+ setInternalAuth(config, identity, toolCall, originalUrl);
420
439
  return formatResult(await executeWithApprovalPolling(gateway, toolCall));
421
440
  },
422
441
  );
@@ -447,19 +466,21 @@ function createMCPServerInstance(gateway: Gateway, config: ResolvedMCPHttpConfig
447
466
  if (capError) return permissionDeniedError(capError);
448
467
 
449
468
  const { constraints, context } = extractCommon(a);
469
+ const originalUrl = a.url as string;
470
+ const resolvedUrl = rewriteSelfUrl(config, originalUrl);
450
471
  const toolCall = buildToolCall(config, {
451
472
  toolName: 'http.get',
452
473
  capability: 'read',
453
474
  args: {
454
475
  method: 'GET',
455
- url: a.url as string,
476
+ url: resolvedUrl,
456
477
  headers: a.headers as Record<string, string> | undefined,
457
478
  },
458
479
  constraints,
459
480
  context,
460
481
  identity,
461
482
  });
462
- setInternalAuth(config, identity, toolCall, a.url as string);
483
+ setInternalAuth(config, identity, toolCall, originalUrl);
463
484
  return formatResult(await executeWithApprovalPolling(gateway, toolCall));
464
485
  },
465
486
  );
@@ -491,12 +512,14 @@ function createMCPServerInstance(gateway: Gateway, config: ResolvedMCPHttpConfig
491
512
  if (capError) return permissionDeniedError(capError);
492
513
 
493
514
  const { constraints, context } = extractCommon(a);
515
+ const originalUrl = a.url as string;
516
+ const resolvedUrl = rewriteSelfUrl(config, originalUrl);
494
517
  const toolCall = buildToolCall(config, {
495
518
  toolName: 'http.post',
496
519
  capability: 'write',
497
520
  args: {
498
521
  method: 'POST',
499
- url: a.url as string,
522
+ url: resolvedUrl,
500
523
  headers: a.headers as Record<string, string> | undefined,
501
524
  body: typeof a.body === 'string' ? parseBody(a.body) : undefined,
502
525
  },
@@ -504,7 +527,7 @@ function createMCPServerInstance(gateway: Gateway, config: ResolvedMCPHttpConfig
504
527
  context,
505
528
  identity,
506
529
  });
507
- setInternalAuth(config, identity, toolCall, a.url as string);
530
+ setInternalAuth(config, identity, toolCall, originalUrl);
508
531
  return formatResult(await executeWithApprovalPolling(gateway, toolCall));
509
532
  },
510
533
  );
@@ -12,6 +12,91 @@ import {
12
12
  PolicyTransformation,
13
13
  } from '../types/policy';
14
14
 
15
+ /**
16
+ * Normalize non-standard IPv4 representations to standard dotted-decimal.
17
+ * Handles:
18
+ * - Octal notation: 0177.0.0.01 → 127.0.0.1
19
+ * - Hex notation: 0x7f.0.0.1 → 127.0.0.1
20
+ * - Short forms: 127.1 → 127.0.0.1, 127.0.1 → 127.0.0.1
21
+ * - 32-bit integer: 2130706433 → 127.0.0.1
22
+ * - Percent-encoded dots: 127%2e0%2e0%2e1 → 127.0.0.1
23
+ * Returns normalized A.B.C.D string, or null if not a valid IPv4.
24
+ */
25
+ function normalizeIPv4(hostname: string): string | null {
26
+ let decoded: string;
27
+ try {
28
+ decoded = decodeURIComponent(hostname);
29
+ } catch {
30
+ decoded = hostname;
31
+ }
32
+
33
+ const parts = decoded.split('.');
34
+
35
+ // Parse a single part: handles hex (0x...), octal (0...), and decimal
36
+ function parsePart(s: string): number | null {
37
+ if (s === '') return null;
38
+ let val: number;
39
+ if (/^0x[0-9a-fA-F]+$/i.test(s)) {
40
+ val = parseInt(s, 16);
41
+ } else if (/^0[0-9]+$/.test(s) && s.length > 1) {
42
+ // Octal: starts with 0 and has more than 1 digit
43
+ val = parseInt(s, 8);
44
+ } else if (/^[0-9]+$/.test(s)) {
45
+ val = parseInt(s, 10);
46
+ } else {
47
+ return null;
48
+ }
49
+ return isFinite(val) && val >= 0 ? val : null;
50
+ }
51
+
52
+ let a: number, b: number, c: number, d: number;
53
+
54
+ if (parts.length === 1) {
55
+ // Single 32-bit integer: 2130706433 → 127.0.0.1
56
+ const val = parsePart(parts[0]);
57
+ if (val === null || val > 0xffffffff) return null;
58
+ a = (val >>> 24) & 0xff;
59
+ b = (val >>> 16) & 0xff;
60
+ c = (val >>> 8) & 0xff;
61
+ d = val & 0xff;
62
+ } else if (parts.length === 2) {
63
+ // Two parts: a.b → a.0.0.b
64
+ const p0 = parsePart(parts[0]);
65
+ const p1 = parsePart(parts[1]);
66
+ if (p0 === null || p1 === null || p0 > 0xff || p1 > 0xffffff) return null;
67
+ a = p0;
68
+ b = (p1 >>> 16) & 0xff;
69
+ c = (p1 >>> 8) & 0xff;
70
+ d = p1 & 0xff;
71
+ } else if (parts.length === 3) {
72
+ // Three parts: a.b.c → a.b.0.c
73
+ const p0 = parsePart(parts[0]);
74
+ const p1 = parsePart(parts[1]);
75
+ const p2 = parsePart(parts[2]);
76
+ if (p0 === null || p1 === null || p2 === null || p0 > 0xff || p1 > 0xff || p2 > 0xffff) return null;
77
+ a = p0;
78
+ b = p1;
79
+ c = (p2 >>> 8) & 0xff;
80
+ d = p2 & 0xff;
81
+ } else if (parts.length === 4) {
82
+ // Standard four parts
83
+ const p0 = parsePart(parts[0]);
84
+ const p1 = parsePart(parts[1]);
85
+ const p2 = parsePart(parts[2]);
86
+ const p3 = parsePart(parts[3]);
87
+ if (p0 === null || p1 === null || p2 === null || p3 === null) return null;
88
+ if (p0 > 0xff || p1 > 0xff || p2 > 0xff || p3 > 0xff) return null;
89
+ a = p0;
90
+ b = p1;
91
+ c = p2;
92
+ d = p3;
93
+ } else {
94
+ return null;
95
+ }
96
+
97
+ return `${a}.${b}.${c}.${d}`;
98
+ }
99
+
15
100
  /**
16
101
  * Check if a hostname resolves to a private/internal IP address or is a
17
102
  * well-known loopback/link-local name. Used for SSRF protection.
@@ -19,6 +104,17 @@ import {
19
104
  function isPrivateTarget(hostname: string): boolean {
20
105
  const lower = hostname.toLowerCase();
21
106
 
107
+ // URL-decode to catch percent-encoded dots (e.g., 127%2e0%2e0%2e1)
108
+ let effectiveHost: string;
109
+ try {
110
+ effectiveHost = decodeURIComponent(lower);
111
+ } catch {
112
+ effectiveHost = lower;
113
+ }
114
+ if (effectiveHost !== lower) {
115
+ return isPrivateTarget(effectiveHost);
116
+ }
117
+
22
118
  // Well-known private hostnames
23
119
  if (lower === 'localhost' || lower === '0.0.0.0') {
24
120
  return true;
@@ -81,6 +177,12 @@ function isPrivateTarget(hostname: string): boolean {
81
177
  return isPrivateTarget(compatMatch[1]);
82
178
  }
83
179
 
180
+ // Normalize non-standard IPv4 (octal, hex, short notation)
181
+ const normalizedIP = normalizeIPv4(bare);
182
+ if (normalizedIP && normalizedIP !== bare) {
183
+ return isPrivateTarget(normalizedIP);
184
+ }
185
+
84
186
  // Check if it's an IPv4 address
85
187
  if (net.isIPv4(bare)) {
86
188
  const parts = bare.split('.').map(Number);
@@ -16,6 +16,11 @@ import { PlanEnforcer } from '../billing/plan-enforcer';
16
16
  import { PlanTier } from '../types/subscription';
17
17
  import { MODEL_PRICING, resolveModelPricing } from '../budget/model-pricing';
18
18
 
19
+ /** Strip HTML tags from user-provided display strings to prevent stored XSS. */
20
+ function stripHtmlTags(input: string): string {
21
+ return input.replace(/<[^>]*>/g, '').trim();
22
+ }
23
+
19
24
  export interface SaaSRouteDeps {
20
25
  config: GatewayConfig;
21
26
  userStore: UserStore;
@@ -82,8 +87,14 @@ export function createSaaSRouter(deps: SaaSRouteDeps): Router {
82
87
  return;
83
88
  }
84
89
 
90
+ const sanitizedDisplayName = stripHtmlTags(display_name);
91
+ if (sanitizedDisplayName.length === 0) {
92
+ res.status(400).json({ error: 'display_name must not be empty after sanitization' });
93
+ return;
94
+ }
95
+
85
96
  const updated = userStore.update(user.id, {
86
- display_name: display_name.trim(),
97
+ display_name: sanitizedDisplayName,
87
98
  updated_at: new Date().toISOString(),
88
99
  });
89
100
  res.json(updated);
@@ -124,6 +135,12 @@ export function createSaaSRouter(deps: SaaSRouteDeps): Router {
124
135
  return;
125
136
  }
126
137
 
138
+ const sanitizedName = stripHtmlTags(name);
139
+ if (sanitizedName.length === 0) {
140
+ res.status(400).json({ error: 'name must not be empty after sanitization' });
141
+ return;
142
+ }
143
+
127
144
  // Enforce workspace limit based on user's highest plan
128
145
  const userWorkspaces = workspaceMemberStore.getByUser(user.id)
129
146
  .filter(m => m.role === 'owner')
@@ -144,8 +161,8 @@ export function createSaaSRouter(deps: SaaSRouteDeps): Router {
144
161
  return;
145
162
  }
146
163
 
147
- // Generate slug from name if not provided
148
- const wsSlug = (slug || name).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 63);
164
+ // Generate slug from sanitized name if not provided
165
+ const wsSlug = (slug || sanitizedName).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').slice(0, 63);
149
166
 
150
167
  if (workspaceStore.getBySlug(wsSlug)) {
151
168
  res.status(409).json({ error: 'Workspace slug already taken' });
@@ -157,7 +174,7 @@ export function createSaaSRouter(deps: SaaSRouteDeps): Router {
157
174
 
158
175
  workspaceStore.create({
159
176
  id: workspaceId,
160
- name: name.trim(),
177
+ name: sanitizedName,
161
178
  slug: wsSlug,
162
179
  owner_user_id: user.id,
163
180
  plan: 'free',
@@ -398,7 +415,7 @@ export function createSaaSRouter(deps: SaaSRouteDeps): Router {
398
415
  key_prefix: keyPrefix,
399
416
  user_id: user.id,
400
417
  workspace_id: workspaceId,
401
- name: name.trim(),
418
+ name: stripHtmlTags(name),
402
419
  roles: Array.isArray(roles) ? roles : ['agent'],
403
420
  tags: parsedTags,
404
421
  revoked: false,
package/src/server/app.ts CHANGED
@@ -265,6 +265,12 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
265
265
  // Register noop executors for non-HTTP tools (e.g. pre-flight validation from Android)
266
266
  gateway.registerExecutor('web_search', new NoopExecutor({ body: { validated: true, tool: 'web_search' } }));
267
267
  gateway.registerExecutor('chat.completion', new NoopExecutor({ body: { validated: true, tool: 'chat.completion' } }));
268
+ gateway.registerExecutor('browser.action', new NoopExecutor({ body: { validated: true, tool: 'browser.action' } }));
269
+ // Noop executors for Claude Code hook-based tools (policy eval + audit only)
270
+ gateway.registerExecutor('file.write', new NoopExecutor({ body: { validated: true, tool: 'file.write' } }));
271
+ gateway.registerExecutor('git.action', new NoopExecutor({ body: { validated: true, tool: 'git.action' } }));
272
+ gateway.registerExecutor('file.sensitive_write', new NoopExecutor({ body: { validated: true, tool: 'file.sensitive_write' } }));
273
+ gateway.registerExecutor('package.install', new NoopExecutor({ body: { validated: true, tool: 'package.install' } }));
268
274
 
269
275
  // Rate limiter for unauthenticated endpoints (60 requests per minute per IP)
270
276
  const publicLimitConfig = config.public_rate_limit || { max_per_window: 60, window_ms: 60000 };
@@ -860,6 +866,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
860
866
  || (isProduction ? 'https://app.palaryn.com' : `http://localhost:${config.port}`);
861
867
  const mcpHandler = createMCPHttpHandler(gateway, {
862
868
  gateway_base_url: config.mcp_oauth?.enabled ? mcpBaseUrl : undefined,
869
+ local_port: config.port,
863
870
  });
864
871
 
865
872
  if (config.mcp_oauth?.enabled) {