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.
- package/README.md +2 -1
- package/dist/src/auth/routes.d.ts.map +1 -1
- package/dist/src/auth/routes.js +5 -1
- package/dist/src/auth/routes.js.map +1 -1
- package/dist/src/config/defaults.d.ts.map +1 -1
- package/dist/src/config/defaults.js +7 -2
- package/dist/src/config/defaults.js.map +1 -1
- package/dist/src/dlp/composite-scanner.d.ts.map +1 -1
- package/dist/src/dlp/composite-scanner.js +26 -1
- package/dist/src/dlp/composite-scanner.js.map +1 -1
- package/dist/src/dlp/heuristic-scorer.d.ts +31 -0
- package/dist/src/dlp/heuristic-scorer.d.ts.map +1 -0
- package/dist/src/dlp/heuristic-scorer.js +314 -0
- package/dist/src/dlp/heuristic-scorer.js.map +1 -0
- package/dist/src/dlp/llm-classifier.d.ts +38 -0
- package/dist/src/dlp/llm-classifier.d.ts.map +1 -0
- package/dist/src/dlp/llm-classifier.js +152 -0
- package/dist/src/dlp/llm-classifier.js.map +1 -0
- package/dist/src/dlp/patterns.d.ts.map +1 -1
- package/dist/src/dlp/patterns.js +1 -0
- package/dist/src/dlp/patterns.js.map +1 -1
- package/dist/src/dlp/prompt-injection-backend.d.ts.map +1 -1
- package/dist/src/dlp/prompt-injection-backend.js +17 -0
- package/dist/src/dlp/prompt-injection-backend.js.map +1 -1
- package/dist/src/dlp/prompt-injection-patterns.d.ts.map +1 -1
- package/dist/src/dlp/prompt-injection-patterns.js +36 -0
- package/dist/src/dlp/prompt-injection-patterns.js.map +1 -1
- package/dist/src/dlp/regex-backend.d.ts.map +1 -1
- package/dist/src/dlp/regex-backend.js +2 -38
- package/dist/src/dlp/regex-backend.js.map +1 -1
- package/dist/src/dlp/scanner.d.ts.map +1 -1
- package/dist/src/dlp/scanner.js +38 -6
- package/dist/src/dlp/scanner.js.map +1 -1
- package/dist/src/dlp/text-normalizer.d.ts +10 -1
- package/dist/src/dlp/text-normalizer.d.ts.map +1 -1
- package/dist/src/dlp/text-normalizer.js +124 -2
- package/dist/src/dlp/text-normalizer.js.map +1 -1
- package/dist/src/mcp/http-transport.d.ts +2 -0
- package/dist/src/mcp/http-transport.d.ts.map +1 -1
- package/dist/src/mcp/http-transport.js +25 -6
- package/dist/src/mcp/http-transport.js.map +1 -1
- package/dist/src/policy/engine.d.ts.map +1 -1
- package/dist/src/policy/engine.js +109 -0
- package/dist/src/policy/engine.js.map +1 -1
- package/dist/src/saas/routes.d.ts.map +1 -1
- package/dist/src/saas/routes.js +19 -5
- package/dist/src/saas/routes.js.map +1 -1
- package/dist/src/server/app.d.ts.map +1 -1
- package/dist/src/server/app.js +7 -0
- package/dist/src/server/app.js.map +1 -1
- package/dist/src/server/gateway.d.ts +1 -0
- package/dist/src/server/gateway.d.ts.map +1 -1
- package/dist/src/server/gateway.js +160 -1
- package/dist/src/server/gateway.js.map +1 -1
- package/dist/src/types/config.d.ts +14 -1
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/tests/security/pentest-payloads.d.ts +46 -0
- package/dist/tests/security/pentest-payloads.d.ts.map +1 -0
- package/dist/tests/security/pentest-payloads.js +475 -0
- package/dist/tests/security/pentest-payloads.js.map +1 -0
- package/dist/tests/unit/adversarial-pipeline.test.d.ts +15 -0
- package/dist/tests/unit/adversarial-pipeline.test.d.ts.map +1 -0
- package/dist/tests/unit/adversarial-pipeline.test.js +1557 -0
- package/dist/tests/unit/adversarial-pipeline.test.js.map +1 -0
- package/dist/tests/unit/dlp-scanner.test.js +5 -5
- package/dist/tests/unit/gateway-branches.test.js +137 -0
- package/dist/tests/unit/gateway-branches.test.js.map +1 -1
- package/dist/tests/unit/heuristic-scorer.test.d.ts +2 -0
- package/dist/tests/unit/heuristic-scorer.test.d.ts.map +1 -0
- package/dist/tests/unit/heuristic-scorer.test.js +248 -0
- package/dist/tests/unit/heuristic-scorer.test.js.map +1 -0
- package/dist/tests/unit/llm-classifier.test.d.ts +2 -0
- package/dist/tests/unit/llm-classifier.test.d.ts.map +1 -0
- package/dist/tests/unit/llm-classifier.test.js +349 -0
- package/dist/tests/unit/llm-classifier.test.js.map +1 -0
- package/dist/tests/unit/prompt-injection-backend.test.js +122 -0
- package/dist/tests/unit/prompt-injection-backend.test.js.map +1 -1
- package/dist/tests/unit/text-normalizer.test.js +52 -1
- package/dist/tests/unit/text-normalizer.test.js.map +1 -1
- package/package.json +1 -1
- package/policy-packs/default.yaml +88 -0
- package/src/auth/routes.ts +6 -1
- package/src/config/defaults.ts +7 -2
- package/src/dlp/composite-scanner.ts +27 -1
- package/src/dlp/heuristic-scorer.ts +342 -0
- package/src/dlp/llm-classifier.ts +191 -0
- package/src/dlp/patterns.ts +1 -0
- package/src/dlp/prompt-injection-backend.ts +19 -1
- package/src/dlp/prompt-injection-patterns.ts +38 -0
- package/src/dlp/regex-backend.ts +2 -45
- package/src/dlp/scanner.ts +36 -6
- package/src/dlp/text-normalizer.ts +130 -2
- package/src/mcp/http-transport.ts +29 -6
- package/src/policy/engine.ts +102 -0
- package/src/saas/routes.ts +22 -5
- package/src/server/app.ts +7 -0
- package/src/server/gateway.ts +196 -1
- package/src/types/config.ts +15 -1
package/src/dlp/regex-backend.ts
CHANGED
|
@@ -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 =
|
|
75
|
+
const normalized = normalizeText(value);
|
|
119
76
|
const detections: DLPDetection[] = [];
|
|
120
77
|
|
|
121
78
|
if (this.secretsEnabled) {
|
package/src/dlp/scanner.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
530
|
+
setInternalAuth(config, identity, toolCall, originalUrl);
|
|
508
531
|
return formatResult(await executeWithApprovalPolling(gateway, toolCall));
|
|
509
532
|
},
|
|
510
533
|
);
|
package/src/policy/engine.ts
CHANGED
|
@@ -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);
|
package/src/saas/routes.ts
CHANGED
|
@@ -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:
|
|
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 ||
|
|
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:
|
|
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
|
|
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) {
|