tlc-claude-code 1.4.8 → 1.4.9
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/package.json +1 -1
- package/server/index.js +229 -14
- package/server/lib/compliance/control-mapper.js +401 -0
- package/server/lib/compliance/control-mapper.test.js +117 -0
- package/server/lib/compliance/evidence-linker.js +296 -0
- package/server/lib/compliance/evidence-linker.test.js +121 -0
- package/server/lib/compliance/gdpr-checklist.js +416 -0
- package/server/lib/compliance/gdpr-checklist.test.js +131 -0
- package/server/lib/compliance/hipaa-checklist.js +277 -0
- package/server/lib/compliance/hipaa-checklist.test.js +101 -0
- package/server/lib/compliance/iso27001-checklist.js +287 -0
- package/server/lib/compliance/iso27001-checklist.test.js +99 -0
- package/server/lib/compliance/multi-framework-reporter.js +284 -0
- package/server/lib/compliance/multi-framework-reporter.test.js +127 -0
- package/server/lib/compliance/pci-dss-checklist.js +214 -0
- package/server/lib/compliance/pci-dss-checklist.test.js +95 -0
- package/server/lib/compliance/trust-centre.js +187 -0
- package/server/lib/compliance/trust-centre.test.js +93 -0
- package/server/lib/dashboard/api-server.js +155 -0
- package/server/lib/dashboard/api-server.test.js +155 -0
- package/server/lib/dashboard/health-api.js +199 -0
- package/server/lib/dashboard/health-api.test.js +122 -0
- package/server/lib/dashboard/notes-api.js +234 -0
- package/server/lib/dashboard/notes-api.test.js +134 -0
- package/server/lib/dashboard/router-api.js +176 -0
- package/server/lib/dashboard/router-api.test.js +132 -0
- package/server/lib/dashboard/tasks-api.js +289 -0
- package/server/lib/dashboard/tasks-api.test.js +161 -0
- package/server/lib/dashboard/tlc-introspection.js +197 -0
- package/server/lib/dashboard/tlc-introspection.test.js +138 -0
- package/server/lib/dashboard/version-api.js +222 -0
- package/server/lib/dashboard/version-api.test.js +112 -0
- package/server/lib/dashboard/websocket-server.js +104 -0
- package/server/lib/dashboard/websocket-server.test.js +118 -0
- package/server/lib/deploy/branch-classifier.js +163 -0
- package/server/lib/deploy/branch-classifier.test.js +164 -0
- package/server/lib/deploy/deployment-approval.js +299 -0
- package/server/lib/deploy/deployment-approval.test.js +296 -0
- package/server/lib/deploy/deployment-audit.js +374 -0
- package/server/lib/deploy/deployment-audit.test.js +307 -0
- package/server/lib/deploy/deployment-executor.js +335 -0
- package/server/lib/deploy/deployment-executor.test.js +329 -0
- package/server/lib/deploy/deployment-rules.js +163 -0
- package/server/lib/deploy/deployment-rules.test.js +188 -0
- package/server/lib/deploy/rollback-manager.js +379 -0
- package/server/lib/deploy/rollback-manager.test.js +321 -0
- package/server/lib/deploy/security-gates.js +236 -0
- package/server/lib/deploy/security-gates.test.js +222 -0
- package/server/lib/k8s/gitops-config.js +188 -0
- package/server/lib/k8s/gitops-config.test.js +59 -0
- package/server/lib/k8s/helm-generator.js +196 -0
- package/server/lib/k8s/helm-generator.test.js +59 -0
- package/server/lib/k8s/kustomize-generator.js +176 -0
- package/server/lib/k8s/kustomize-generator.test.js +58 -0
- package/server/lib/k8s/network-policy.js +114 -0
- package/server/lib/k8s/network-policy.test.js +53 -0
- package/server/lib/k8s/pod-security.js +114 -0
- package/server/lib/k8s/pod-security.test.js +55 -0
- package/server/lib/k8s/rbac-generator.js +132 -0
- package/server/lib/k8s/rbac-generator.test.js +57 -0
- package/server/lib/k8s/resource-manager.js +172 -0
- package/server/lib/k8s/resource-manager.test.js +60 -0
- package/server/lib/k8s/secrets-encryption.js +168 -0
- package/server/lib/k8s/secrets-encryption.test.js +49 -0
- package/server/lib/monitoring/alert-manager.js +238 -0
- package/server/lib/monitoring/alert-manager.test.js +106 -0
- package/server/lib/monitoring/health-check.js +226 -0
- package/server/lib/monitoring/health-check.test.js +176 -0
- package/server/lib/monitoring/incident-manager.js +230 -0
- package/server/lib/monitoring/incident-manager.test.js +98 -0
- package/server/lib/monitoring/log-aggregator.js +147 -0
- package/server/lib/monitoring/log-aggregator.test.js +89 -0
- package/server/lib/monitoring/metrics-collector.js +337 -0
- package/server/lib/monitoring/metrics-collector.test.js +172 -0
- package/server/lib/monitoring/status-page.js +214 -0
- package/server/lib/monitoring/status-page.test.js +105 -0
- package/server/lib/monitoring/uptime-monitor.js +194 -0
- package/server/lib/monitoring/uptime-monitor.test.js +109 -0
- package/server/lib/network/fail2ban-config.js +294 -0
- package/server/lib/network/fail2ban-config.test.js +275 -0
- package/server/lib/network/firewall-manager.js +252 -0
- package/server/lib/network/firewall-manager.test.js +254 -0
- package/server/lib/network/geoip-filter.js +282 -0
- package/server/lib/network/geoip-filter.test.js +264 -0
- package/server/lib/network/rate-limiter.js +229 -0
- package/server/lib/network/rate-limiter.test.js +293 -0
- package/server/lib/network/request-validator.js +351 -0
- package/server/lib/network/request-validator.test.js +345 -0
- package/server/lib/network/security-headers.js +251 -0
- package/server/lib/network/security-headers.test.js +283 -0
- package/server/lib/network/tls-config.js +210 -0
- package/server/lib/network/tls-config.test.js +248 -0
- package/server/lib/security/auth-security.js +369 -0
- package/server/lib/security/auth-security.test.js +448 -0
- package/server/lib/security/cis-benchmark.js +152 -0
- package/server/lib/security/cis-benchmark.test.js +137 -0
- package/server/lib/security/compose-templates.js +312 -0
- package/server/lib/security/compose-templates.test.js +229 -0
- package/server/lib/security/container-runtime.js +456 -0
- package/server/lib/security/container-runtime.test.js +503 -0
- package/server/lib/security/cors-validator.js +278 -0
- package/server/lib/security/cors-validator.test.js +310 -0
- package/server/lib/security/crypto-utils.js +253 -0
- package/server/lib/security/crypto-utils.test.js +409 -0
- package/server/lib/security/dockerfile-linter.js +459 -0
- package/server/lib/security/dockerfile-linter.test.js +483 -0
- package/server/lib/security/dockerfile-templates.js +278 -0
- package/server/lib/security/dockerfile-templates.test.js +164 -0
- package/server/lib/security/error-sanitizer.js +426 -0
- package/server/lib/security/error-sanitizer.test.js +331 -0
- package/server/lib/security/headers-generator.js +368 -0
- package/server/lib/security/headers-generator.test.js +398 -0
- package/server/lib/security/image-scanner.js +83 -0
- package/server/lib/security/image-scanner.test.js +106 -0
- package/server/lib/security/input-validator.js +352 -0
- package/server/lib/security/input-validator.test.js +330 -0
- package/server/lib/security/network-policy.js +174 -0
- package/server/lib/security/network-policy.test.js +164 -0
- package/server/lib/security/output-encoder.js +237 -0
- package/server/lib/security/output-encoder.test.js +276 -0
- package/server/lib/security/path-validator.js +359 -0
- package/server/lib/security/path-validator.test.js +293 -0
- package/server/lib/security/query-builder.js +421 -0
- package/server/lib/security/query-builder.test.js +318 -0
- package/server/lib/security/secret-detector.js +290 -0
- package/server/lib/security/secret-detector.test.js +354 -0
- package/server/lib/security/secrets-validator.js +137 -0
- package/server/lib/security/secrets-validator.test.js +120 -0
- package/server/lib/security-testing/dast-runner.js +154 -0
- package/server/lib/security-testing/dast-runner.test.js +62 -0
- package/server/lib/security-testing/dependency-scanner.js +172 -0
- package/server/lib/security-testing/dependency-scanner.test.js +64 -0
- package/server/lib/security-testing/pentest-runner.js +230 -0
- package/server/lib/security-testing/pentest-runner.test.js +60 -0
- package/server/lib/security-testing/sast-runner.js +136 -0
- package/server/lib/security-testing/sast-runner.test.js +62 -0
- package/server/lib/security-testing/secret-scanner.js +153 -0
- package/server/lib/security-testing/secret-scanner.test.js +66 -0
- package/server/lib/security-testing/security-gate.js +216 -0
- package/server/lib/security-testing/security-gate.test.js +115 -0
- package/server/lib/security-testing/security-reporter.js +303 -0
- package/server/lib/security-testing/security-reporter.test.js +114 -0
- package/server/lib/standards/audit-checker.js +546 -0
- package/server/lib/standards/audit-checker.test.js +415 -0
- package/server/lib/standards/cleanup-executor.js +452 -0
- package/server/lib/standards/cleanup-executor.test.js +293 -0
- package/server/lib/standards/refactor-stepper.js +425 -0
- package/server/lib/standards/refactor-stepper.test.js +298 -0
- package/server/lib/standards/standards-injector.js +167 -0
- package/server/lib/standards/standards-injector.test.js +232 -0
- package/server/lib/user-management.test.js +284 -0
- package/server/lib/vps/backup-manager.js +157 -0
- package/server/lib/vps/backup-manager.test.js +59 -0
- package/server/lib/vps/caddy-config.js +159 -0
- package/server/lib/vps/caddy-config.test.js +48 -0
- package/server/lib/vps/compose-orchestrator.js +219 -0
- package/server/lib/vps/compose-orchestrator.test.js +50 -0
- package/server/lib/vps/database-config.js +208 -0
- package/server/lib/vps/database-config.test.js +47 -0
- package/server/lib/vps/deploy-script.js +211 -0
- package/server/lib/vps/deploy-script.test.js +53 -0
- package/server/lib/vps/secrets-manager.js +148 -0
- package/server/lib/vps/secrets-manager.test.js +58 -0
- package/server/lib/vps/server-hardening.js +174 -0
- package/server/lib/vps/server-hardening.test.js +70 -0
- package/server/package-lock.json +19 -0
- package/server/package.json +1 -0
- package/server/templates/CLAUDE.md +37 -0
- package/server/templates/CODING-STANDARDS.md +408 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Encoder Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for context-aware output encoding to prevent XSS.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
encodeHtml,
|
|
10
|
+
encodeHtmlAttribute,
|
|
11
|
+
encodeJavaScript,
|
|
12
|
+
encodeUrl,
|
|
13
|
+
encodeCss,
|
|
14
|
+
encodeForContext,
|
|
15
|
+
createEncoder,
|
|
16
|
+
} from './output-encoder.js';
|
|
17
|
+
|
|
18
|
+
describe('output-encoder', () => {
|
|
19
|
+
describe('encodeHtml', () => {
|
|
20
|
+
it('encodes less than sign', () => {
|
|
21
|
+
const result = encodeHtml('<script>');
|
|
22
|
+
expect(result).toBe('<script>');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('encodes greater than sign', () => {
|
|
26
|
+
const result = encodeHtml('a > b');
|
|
27
|
+
expect(result).toBe('a > b');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('encodes ampersand', () => {
|
|
31
|
+
const result = encodeHtml('a & b');
|
|
32
|
+
expect(result).toBe('a & b');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('encodes double quotes', () => {
|
|
36
|
+
const result = encodeHtml('say "hello"');
|
|
37
|
+
expect(result).toBe('say "hello"');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('encodes single quotes', () => {
|
|
41
|
+
const result = encodeHtml("it's fine");
|
|
42
|
+
expect(result).toBe('it's fine');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not double-encode already encoded content', () => {
|
|
46
|
+
const result = encodeHtml('<script>', { skipEncoded: true });
|
|
47
|
+
expect(result).toBe('<script>');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles null input', () => {
|
|
51
|
+
const result = encodeHtml(null);
|
|
52
|
+
expect(result).toBe('');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles undefined input', () => {
|
|
56
|
+
const result = encodeHtml(undefined);
|
|
57
|
+
expect(result).toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('converts numbers to string', () => {
|
|
61
|
+
const result = encodeHtml(123);
|
|
62
|
+
expect(result).toBe('123');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('strips null bytes', () => {
|
|
66
|
+
const result = encodeHtml('hello\x00world');
|
|
67
|
+
expect(result).not.toContain('\x00');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('encodeHtmlAttribute', () => {
|
|
72
|
+
it('encodes for use in attributes', () => {
|
|
73
|
+
const result = encodeHtmlAttribute('value with "quotes"');
|
|
74
|
+
expect(result).toBe('value with "quotes"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('encodes single quotes for single-quoted attributes', () => {
|
|
78
|
+
const result = encodeHtmlAttribute("it's a value");
|
|
79
|
+
expect(result).toContain(''');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('encodes equals sign', () => {
|
|
83
|
+
const result = encodeHtmlAttribute('a=b');
|
|
84
|
+
expect(result).toBe('a=b');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('encodes backticks', () => {
|
|
88
|
+
const result = encodeHtmlAttribute('`code`');
|
|
89
|
+
expect(result).toBe('`code`');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('handles event handler context', () => {
|
|
93
|
+
const result = encodeHtmlAttribute('alert(1)', { context: 'event' });
|
|
94
|
+
expect(result).not.toContain('(');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('encodeJavaScript', () => {
|
|
99
|
+
it('escapes backslashes', () => {
|
|
100
|
+
const result = encodeJavaScript('path\\to\\file');
|
|
101
|
+
expect(result).toBe('path\\\\to\\\\file');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('escapes single quotes', () => {
|
|
105
|
+
const result = encodeJavaScript("it's");
|
|
106
|
+
expect(result).toBe("it\\'s");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('escapes double quotes', () => {
|
|
110
|
+
const result = encodeJavaScript('say "hi"');
|
|
111
|
+
expect(result).toBe('say \\"hi\\"');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('escapes newlines', () => {
|
|
115
|
+
const result = encodeJavaScript('line1\nline2');
|
|
116
|
+
expect(result).toBe('line1\\nline2');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('escapes carriage returns', () => {
|
|
120
|
+
const result = encodeJavaScript('line1\rline2');
|
|
121
|
+
expect(result).toBe('line1\\rline2');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('escapes forward slashes to prevent </script> breaking', () => {
|
|
125
|
+
const result = encodeJavaScript('</script>');
|
|
126
|
+
expect(result).toBe('<\\/script>');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('escapes unicode line separators', () => {
|
|
130
|
+
const result = encodeJavaScript('text\u2028more');
|
|
131
|
+
expect(result).toBe('text\\u2028more');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('escapes unicode paragraph separators', () => {
|
|
135
|
+
const result = encodeJavaScript('text\u2029more');
|
|
136
|
+
expect(result).toBe('text\\u2029more');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('encodeUrl', () => {
|
|
141
|
+
it('encodes spaces', () => {
|
|
142
|
+
const result = encodeUrl('hello world');
|
|
143
|
+
expect(result).toBe('hello%20world');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('encodes special characters', () => {
|
|
147
|
+
const result = encodeUrl('a=b&c=d');
|
|
148
|
+
expect(result).toBe('a%3Db%26c%3Dd');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('preserves alphanumeric characters', () => {
|
|
152
|
+
const result = encodeUrl('abc123');
|
|
153
|
+
expect(result).toBe('abc123');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('preserves safe URL characters when configured', () => {
|
|
157
|
+
const result = encodeUrl('path/to/file', { preservePath: true });
|
|
158
|
+
expect(result).toBe('path/to/file');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('encodes unicode characters', () => {
|
|
162
|
+
const result = encodeUrl('café');
|
|
163
|
+
expect(result).toBe('caf%C3%A9');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('handles empty string', () => {
|
|
167
|
+
const result = encodeUrl('');
|
|
168
|
+
expect(result).toBe('');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('encodeCss', () => {
|
|
173
|
+
it('escapes backslashes', () => {
|
|
174
|
+
const result = encodeCss('url(\\path)');
|
|
175
|
+
expect(result).toContain('\\\\');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('escapes quotes', () => {
|
|
179
|
+
const result = encodeCss('font-family: "Arial"');
|
|
180
|
+
expect(result).toContain('\\"'); // Escaped with backslash
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('escapes semicolons', () => {
|
|
184
|
+
const result = encodeCss('value; other-property');
|
|
185
|
+
expect(result).toContain('\\;'); // Escaped with backslash
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('escapes curly braces', () => {
|
|
189
|
+
const result = encodeCss('value { injection }');
|
|
190
|
+
expect(result).toContain('\\{'); // Escaped with backslash
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('blocks url() injection', () => {
|
|
194
|
+
const result = encodeCss('url(javascript:alert(1))');
|
|
195
|
+
expect(result).not.toContain('javascript:');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('blocks expression() for IE', () => {
|
|
199
|
+
const result = encodeCss('expression(alert(1))');
|
|
200
|
+
expect(result).not.toContain('expression');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('encodeForContext', () => {
|
|
205
|
+
it('auto-detects HTML context', () => {
|
|
206
|
+
const result = encodeForContext('<script>', 'html');
|
|
207
|
+
expect(result).toBe('<script>');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('auto-detects JavaScript context', () => {
|
|
211
|
+
const result = encodeForContext("it's", 'javascript');
|
|
212
|
+
expect(result).toBe("it\\'s");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('auto-detects URL context', () => {
|
|
216
|
+
const result = encodeForContext('hello world', 'url');
|
|
217
|
+
expect(result).toBe('hello%20world');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('auto-detects CSS context', () => {
|
|
221
|
+
const result = encodeForContext('value;', 'css');
|
|
222
|
+
expect(result).toContain('\\;'); // Semicolon is escaped with backslash
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('auto-detects attribute context', () => {
|
|
226
|
+
const result = encodeForContext('value"', 'attribute');
|
|
227
|
+
expect(result).toContain('"');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('throws for unknown context', () => {
|
|
231
|
+
expect(() => encodeForContext('test', 'unknown')).toThrow();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('createEncoder', () => {
|
|
236
|
+
it('creates encoder with default context', () => {
|
|
237
|
+
const encoder = createEncoder({ defaultContext: 'html' });
|
|
238
|
+
const result = encoder.encode('<script>').value();
|
|
239
|
+
expect(result).toBe('<script>');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('allows context override', () => {
|
|
243
|
+
const encoder = createEncoder({ defaultContext: 'html' });
|
|
244
|
+
const result = encoder.encode('hello world', 'url').value();
|
|
245
|
+
expect(result).toBe('hello%20world');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('chains encoding operations', () => {
|
|
249
|
+
const encoder = createEncoder();
|
|
250
|
+
const result = encoder
|
|
251
|
+
.encode('<script>', 'html')
|
|
252
|
+
.then((v) => v.toUpperCase())
|
|
253
|
+
.value();
|
|
254
|
+
expect(result).toBe('<SCRIPT>');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('unicode handling', () => {
|
|
259
|
+
it('preserves valid unicode in HTML', () => {
|
|
260
|
+
const result = encodeHtml('Hello 世界 🌍');
|
|
261
|
+
expect(result).toContain('世界');
|
|
262
|
+
expect(result).toContain('🌍');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('preserves valid unicode in JavaScript', () => {
|
|
266
|
+
const result = encodeJavaScript('Hello 世界');
|
|
267
|
+
expect(result).toContain('世界');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('handles surrogate pairs correctly', () => {
|
|
271
|
+
const emoji = '😀';
|
|
272
|
+
const result = encodeHtml(emoji);
|
|
273
|
+
expect(result).toBe('😀');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Validator Module
|
|
3
|
+
*
|
|
4
|
+
* Prevents path traversal attacks (OWASP A01: Broken Access Control)
|
|
5
|
+
* Validates file paths to ensure they stay within allowed directories.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom error for path traversal attempts
|
|
12
|
+
*/
|
|
13
|
+
export class PathTraversalError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'PathTraversalError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a path by resolving dots, decoding URL encoding, etc.
|
|
22
|
+
* @param {string} inputPath - Path to normalize
|
|
23
|
+
* @returns {string} Normalized path
|
|
24
|
+
*/
|
|
25
|
+
export function normalizePath(inputPath) {
|
|
26
|
+
if (!inputPath) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let normalized = inputPath;
|
|
31
|
+
|
|
32
|
+
// Decode URL encoding (including double encoding)
|
|
33
|
+
try {
|
|
34
|
+
// Decode multiple times to catch double encoding
|
|
35
|
+
let decoded = normalized;
|
|
36
|
+
let prevDecoded;
|
|
37
|
+
do {
|
|
38
|
+
prevDecoded = decoded;
|
|
39
|
+
decoded = decodeURIComponent(decoded);
|
|
40
|
+
} while (decoded !== prevDecoded && decoded.includes('%'));
|
|
41
|
+
normalized = decoded;
|
|
42
|
+
} catch {
|
|
43
|
+
// If decoding fails, keep original
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Convert Windows path separators to forward slashes for consistent handling
|
|
47
|
+
normalized = normalized.replace(/\\/g, '/');
|
|
48
|
+
|
|
49
|
+
// Collapse multiple slashes
|
|
50
|
+
normalized = normalized.replace(/\/+/g, '/');
|
|
51
|
+
|
|
52
|
+
// Use path.normalize to resolve . and ..
|
|
53
|
+
normalized = path.normalize(normalized);
|
|
54
|
+
|
|
55
|
+
// Remove trailing slash (unless it's the root)
|
|
56
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
57
|
+
normalized = normalized.slice(0, -1);
|
|
58
|
+
}
|
|
59
|
+
if (normalized.length > 1 && normalized.endsWith(path.sep)) {
|
|
60
|
+
normalized = normalized.slice(0, -1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a path is within a base directory
|
|
68
|
+
* @param {string} targetPath - Path to check
|
|
69
|
+
* @param {string} baseDir - Base directory
|
|
70
|
+
* @returns {boolean} True if within base
|
|
71
|
+
*/
|
|
72
|
+
export function isWithinBase(targetPath, baseDir) {
|
|
73
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
74
|
+
const normalizedBase = normalizePath(baseDir);
|
|
75
|
+
|
|
76
|
+
// Ensure base ends with separator for prefix matching
|
|
77
|
+
const basePrefix = normalizedBase.endsWith(path.sep)
|
|
78
|
+
? normalizedBase
|
|
79
|
+
: normalizedBase + path.sep;
|
|
80
|
+
|
|
81
|
+
// Check if target starts with base or equals base
|
|
82
|
+
return (
|
|
83
|
+
normalizedTarget === normalizedBase ||
|
|
84
|
+
normalizedTarget.startsWith(basePrefix)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a file path against security constraints
|
|
90
|
+
* @param {string} inputPath - Path to validate
|
|
91
|
+
* @param {Object} options - Validation options
|
|
92
|
+
* @returns {Object} Validation result
|
|
93
|
+
*/
|
|
94
|
+
export function validatePath(inputPath, options = {}) {
|
|
95
|
+
const { baseDir } = options;
|
|
96
|
+
|
|
97
|
+
// Check for empty path
|
|
98
|
+
if (!inputPath || inputPath.trim() === '') {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
threat: 'empty_path',
|
|
102
|
+
error: 'Path is empty',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check for null bytes
|
|
107
|
+
if (inputPath.includes('\x00')) {
|
|
108
|
+
return {
|
|
109
|
+
valid: false,
|
|
110
|
+
threat: 'null_byte',
|
|
111
|
+
error: 'Path contains null byte',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check for control characters (ASCII 0-31 except tab, newline)
|
|
116
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(inputPath)) {
|
|
117
|
+
return {
|
|
118
|
+
valid: false,
|
|
119
|
+
threat: 'control_characters',
|
|
120
|
+
error: 'Path contains control characters',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check for path with only dots
|
|
125
|
+
if (/^\.+$/.test(inputPath.replace(/[\/\\]/g, ''))) {
|
|
126
|
+
return {
|
|
127
|
+
valid: false,
|
|
128
|
+
threat: 'invalid_path',
|
|
129
|
+
error: 'Path contains only dots',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Decode and normalize path
|
|
134
|
+
let normalizedPath = inputPath;
|
|
135
|
+
|
|
136
|
+
// Decode URL encoding to detect encoded traversal attempts
|
|
137
|
+
try {
|
|
138
|
+
let decoded = normalizedPath;
|
|
139
|
+
let prevDecoded;
|
|
140
|
+
let iterations = 0;
|
|
141
|
+
do {
|
|
142
|
+
prevDecoded = decoded;
|
|
143
|
+
decoded = decodeURIComponent(decoded);
|
|
144
|
+
iterations++;
|
|
145
|
+
} while (decoded !== prevDecoded && decoded.includes('%') && iterations < 5);
|
|
146
|
+
normalizedPath = decoded;
|
|
147
|
+
} catch {
|
|
148
|
+
// If decoding fails, use original
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check for traversal patterns (before normalization)
|
|
152
|
+
if (normalizedPath.includes('..')) {
|
|
153
|
+
// Normalize to see where it actually points
|
|
154
|
+
const normalized = normalizePath(normalizedPath);
|
|
155
|
+
const baseNormalized = normalizePath(baseDir);
|
|
156
|
+
|
|
157
|
+
if (!isWithinBase(normalized, baseNormalized)) {
|
|
158
|
+
return {
|
|
159
|
+
valid: false,
|
|
160
|
+
threat: 'path_traversal',
|
|
161
|
+
error: 'Path traversal detected',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Handle relative paths - resolve against base
|
|
167
|
+
let resolvedPath;
|
|
168
|
+
if (path.isAbsolute(normalizedPath)) {
|
|
169
|
+
resolvedPath = normalizePath(normalizedPath);
|
|
170
|
+
} else {
|
|
171
|
+
resolvedPath = normalizePath(path.join(baseDir, normalizedPath));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Final check: ensure resolved path is within base
|
|
175
|
+
if (!isWithinBase(resolvedPath, baseDir)) {
|
|
176
|
+
return {
|
|
177
|
+
valid: false,
|
|
178
|
+
threat: 'outside_base',
|
|
179
|
+
error: 'Path is outside allowed directory',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
valid: true,
|
|
185
|
+
normalizedPath: resolvedPath,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validate file extension
|
|
191
|
+
* @param {string} filename - Filename to check
|
|
192
|
+
* @param {Object} options - Validation options
|
|
193
|
+
* @returns {Object} Validation result
|
|
194
|
+
*/
|
|
195
|
+
export function validateExtension(filename, options = {}) {
|
|
196
|
+
const {
|
|
197
|
+
allowed = null,
|
|
198
|
+
blocked = null,
|
|
199
|
+
caseSensitive = true,
|
|
200
|
+
checkDoubleExtension = false,
|
|
201
|
+
rejectHidden = false,
|
|
202
|
+
} = options;
|
|
203
|
+
|
|
204
|
+
// Check for hidden files
|
|
205
|
+
if (rejectHidden && filename.startsWith('.')) {
|
|
206
|
+
return {
|
|
207
|
+
valid: false,
|
|
208
|
+
error: 'Hidden files not allowed',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Get extension
|
|
213
|
+
let ext = path.extname(filename);
|
|
214
|
+
if (!caseSensitive) {
|
|
215
|
+
ext = ext.toLowerCase();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check for double extensions
|
|
219
|
+
if (checkDoubleExtension) {
|
|
220
|
+
const withoutExt = filename.slice(0, -ext.length);
|
|
221
|
+
const secondExt = path.extname(withoutExt);
|
|
222
|
+
if (secondExt) {
|
|
223
|
+
return {
|
|
224
|
+
valid: false,
|
|
225
|
+
error: 'Double extension detected',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check blocked list
|
|
231
|
+
if (blocked) {
|
|
232
|
+
const blockedNormalized = caseSensitive
|
|
233
|
+
? blocked
|
|
234
|
+
: blocked.map((e) => e.toLowerCase());
|
|
235
|
+
if (blockedNormalized.includes(ext)) {
|
|
236
|
+
return {
|
|
237
|
+
valid: false,
|
|
238
|
+
error: `Extension ${ext} is blocked`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check allowed list
|
|
244
|
+
if (allowed) {
|
|
245
|
+
const allowedNormalized = caseSensitive
|
|
246
|
+
? allowed
|
|
247
|
+
: allowed.map((e) => e.toLowerCase());
|
|
248
|
+
if (!allowedNormalized.includes(ext)) {
|
|
249
|
+
return {
|
|
250
|
+
valid: false,
|
|
251
|
+
error: `Extension ${ext} is not allowed`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { valid: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Create a path validator with preset options
|
|
261
|
+
* @param {Object} options - Validator options
|
|
262
|
+
* @returns {Object} Path validator instance
|
|
263
|
+
*/
|
|
264
|
+
export function createPathValidator(options = {}) {
|
|
265
|
+
const {
|
|
266
|
+
baseDirs = [],
|
|
267
|
+
allowedExtensions = null,
|
|
268
|
+
blockedExtensions = null,
|
|
269
|
+
maxPathLength = 4096,
|
|
270
|
+
forbiddenPatterns = [],
|
|
271
|
+
followSymlinks = true,
|
|
272
|
+
} = options;
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
/**
|
|
276
|
+
* Validate a path synchronously
|
|
277
|
+
* @param {string} inputPath - Path to validate
|
|
278
|
+
* @returns {Object} Validation result
|
|
279
|
+
*/
|
|
280
|
+
validate(inputPath) {
|
|
281
|
+
// Check max length
|
|
282
|
+
if (inputPath.length > maxPathLength) {
|
|
283
|
+
return {
|
|
284
|
+
valid: false,
|
|
285
|
+
error: 'Path exceeds maximum length',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Check forbidden patterns
|
|
290
|
+
for (const pattern of forbiddenPatterns) {
|
|
291
|
+
if (pattern.test(inputPath)) {
|
|
292
|
+
return {
|
|
293
|
+
valid: false,
|
|
294
|
+
error: 'Path matches forbidden pattern',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check against each base directory
|
|
300
|
+
let isValidForAnyBase = false;
|
|
301
|
+
let lastResult = null;
|
|
302
|
+
|
|
303
|
+
for (const baseDir of baseDirs) {
|
|
304
|
+
const result = validatePath(inputPath, { baseDir });
|
|
305
|
+
lastResult = result;
|
|
306
|
+
if (result.valid) {
|
|
307
|
+
isValidForAnyBase = true;
|
|
308
|
+
|
|
309
|
+
// Also check extension if configured
|
|
310
|
+
if (allowedExtensions || blockedExtensions) {
|
|
311
|
+
const filename = path.basename(inputPath);
|
|
312
|
+
const extResult = validateExtension(filename, {
|
|
313
|
+
allowed: allowedExtensions,
|
|
314
|
+
blocked: blockedExtensions,
|
|
315
|
+
});
|
|
316
|
+
if (!extResult.valid) {
|
|
317
|
+
return extResult;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!isValidForAnyBase) {
|
|
326
|
+
return lastResult || {
|
|
327
|
+
valid: false,
|
|
328
|
+
error: 'Path is not within any allowed directory',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Validate a path asynchronously (with symlink checking)
|
|
335
|
+
* @param {string} inputPath - Path to validate
|
|
336
|
+
* @param {Object} asyncOptions - Async validation options
|
|
337
|
+
* @returns {Promise<Object>} Validation result
|
|
338
|
+
*/
|
|
339
|
+
async validateAsync(inputPath, asyncOptions = {}) {
|
|
340
|
+
const { checkSymlinks = false } = asyncOptions;
|
|
341
|
+
|
|
342
|
+
// First run sync validation
|
|
343
|
+
const syncResult = this.validate(inputPath);
|
|
344
|
+
if (!syncResult.valid) {
|
|
345
|
+
return syncResult;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// If symlink checking is enabled and we're not following symlinks
|
|
349
|
+
if (checkSymlinks && !followSymlinks) {
|
|
350
|
+
// In a real implementation, we would use fs.lstat here
|
|
351
|
+
// to check if the path is a symlink and where it points
|
|
352
|
+
// For now, return the sync result
|
|
353
|
+
return syncResult;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return syncResult;
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|