jaku.sh 1.0.0
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/LICENSE +52 -0
- package/README.md +636 -0
- package/action.yml +264 -0
- package/bin/jaku +2 -0
- package/package.json +62 -0
- package/src/agents/ai-agent.js +175 -0
- package/src/agents/api-agent.js +95 -0
- package/src/agents/base-agent.js +158 -0
- package/src/agents/crawl-agent.js +175 -0
- package/src/agents/event-bus.js +59 -0
- package/src/agents/findings-ledger.js +410 -0
- package/src/agents/logic-agent.js +144 -0
- package/src/agents/orchestrator.js +323 -0
- package/src/agents/qa-agent.js +149 -0
- package/src/agents/security-agent.js +211 -0
- package/src/cli.js +423 -0
- package/src/core/accessibility-checker.js +171 -0
- package/src/core/ai/ai-endpoint-detector.js +227 -0
- package/src/core/ai/guardrail-prober.js +362 -0
- package/src/core/ai/indirect-injector.js +106 -0
- package/src/core/ai/jailbreak-tester.js +212 -0
- package/src/core/ai/model-dos-tester.js +174 -0
- package/src/core/ai/model-fingerprinter.js +246 -0
- package/src/core/ai/multi-turn-attacker.js +297 -0
- package/src/core/ai/output-analyzer.js +182 -0
- package/src/core/ai/prompt-injector.js +543 -0
- package/src/core/ai/system-prompt-extractor.js +244 -0
- package/src/core/api/api-key-auditor.js +266 -0
- package/src/core/api/auth-flow-tester.js +430 -0
- package/src/core/api/cors-ws-tester.js +263 -0
- package/src/core/api/graphql-tester.js +287 -0
- package/src/core/api/oauth-prober.js +343 -0
- package/src/core/auth-manager.js +902 -0
- package/src/core/broken-flow-detector.js +207 -0
- package/src/core/browser-manager.js +119 -0
- package/src/core/console-monitor.js +111 -0
- package/src/core/crawler.js +430 -0
- package/src/core/csr-waiter.js +410 -0
- package/src/core/form-validator.js +240 -0
- package/src/core/logic/abuse-pattern-scanner.js +291 -0
- package/src/core/logic/access-boundary-tester.js +448 -0
- package/src/core/logic/business-rule-inferrer.js +196 -0
- package/src/core/logic/graphql-auditor.js +298 -0
- package/src/core/logic/parameter-polluter.js +212 -0
- package/src/core/logic/pricing-exploiter.js +299 -0
- package/src/core/logic/race-condition-detector.js +222 -0
- package/src/core/logic/workflow-enforcer.js +284 -0
- package/src/core/performance-checker.js +204 -0
- package/src/core/responsive-checker.js +228 -0
- package/src/core/security/cors-prober.js +150 -0
- package/src/core/security/csrf-prober.js +217 -0
- package/src/core/security/dependency-auditor.js +182 -0
- package/src/core/security/file-upload-tester.js +340 -0
- package/src/core/security/header-analyzer.js +324 -0
- package/src/core/security/infra-scanner.js +391 -0
- package/src/core/security/path-traversal.js +112 -0
- package/src/core/security/prototype-pollution.js +147 -0
- package/src/core/security/secret-detector.js +517 -0
- package/src/core/security/sqli-prober.js +257 -0
- package/src/core/security/tls-checker.js +223 -0
- package/src/core/security/xss-scanner.js +225 -0
- package/src/core/test-generator.js +339 -0
- package/src/core/test-runner.js +398 -0
- package/src/reporting/diff-reporter.js +172 -0
- package/src/reporting/report-generator.js +408 -0
- package/src/reporting/sarif-generator.js +190 -0
- package/src/utils/config.js +57 -0
- package/src/utils/finding.js +67 -0
- package/src/utils/logger.js +50 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GraphQLAuditor — Comprehensive GraphQL security audit.
|
|
5
|
+
*
|
|
6
|
+
* Tests:
|
|
7
|
+
* 1. Introspection enabled — allows schema discovery
|
|
8
|
+
* 2. Batch query amplification — N identical queries in one request
|
|
9
|
+
* 3. Deeply nested query DoS — infinite depth via recursive types
|
|
10
|
+
* 4. Field suggestion extraction — even without introspection
|
|
11
|
+
* 5. Mutation authorization bypass — mutate without auth
|
|
12
|
+
* 6. Alias-based rate limit bypass — 100 alias queries = 1 request
|
|
13
|
+
*/
|
|
14
|
+
export class GraphQLAuditor {
|
|
15
|
+
constructor(logger) {
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
|
|
18
|
+
this.COMMON_ENDPOINTS = ['/graphql', '/api/graphql', '/query', '/gql', '/v1/graphql', '/graphql/v1', '/graphql/v2', '/api/query'];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async audit(surfaceInventory) {
|
|
22
|
+
const findings = [];
|
|
23
|
+
|
|
24
|
+
// Discover GraphQL endpoints
|
|
25
|
+
const baseUrl = surfaceInventory.pages[0]?.url;
|
|
26
|
+
if (!baseUrl) return findings;
|
|
27
|
+
|
|
28
|
+
const origin = new URL(baseUrl).origin;
|
|
29
|
+
const graphqlEndpoints = await this._discoverEndpoints(origin, surfaceInventory);
|
|
30
|
+
|
|
31
|
+
if (graphqlEndpoints.length === 0) {
|
|
32
|
+
this.logger?.info?.('GraphQL Auditor: no GraphQL endpoints found');
|
|
33
|
+
return findings;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.logger?.info?.(`GraphQL Auditor: testing ${graphqlEndpoints.length} endpoint(s)`);
|
|
37
|
+
|
|
38
|
+
for (const endpoint of graphqlEndpoints) {
|
|
39
|
+
// Test 1: Introspection
|
|
40
|
+
const introspectionFinding = await this._testIntrospection(endpoint);
|
|
41
|
+
if (introspectionFinding) findings.push(introspectionFinding);
|
|
42
|
+
|
|
43
|
+
// Test 2: Batch query amplification
|
|
44
|
+
const batchFinding = await this._testBatchAmplification(endpoint);
|
|
45
|
+
if (batchFinding) findings.push(batchFinding);
|
|
46
|
+
|
|
47
|
+
// Test 3: Deep nesting DoS
|
|
48
|
+
const nestingFinding = await this._testDeepNesting(endpoint);
|
|
49
|
+
if (nestingFinding) findings.push(nestingFinding);
|
|
50
|
+
|
|
51
|
+
// Test 4: Field suggestion extraction (works even without introspection)
|
|
52
|
+
const suggestionFinding = await this._testFieldSuggestions(endpoint);
|
|
53
|
+
if (suggestionFinding) findings.push(suggestionFinding);
|
|
54
|
+
|
|
55
|
+
// Test 5: Alias-based rate limit bypass
|
|
56
|
+
const aliasFinding = await this._testAliasBypass(endpoint);
|
|
57
|
+
if (aliasFinding) findings.push(aliasFinding);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.logger?.info?.(`GraphQL Auditor: found ${findings.length} issues`);
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _discoverEndpoints(origin, surfaceInventory) {
|
|
65
|
+
const endpoints = [];
|
|
66
|
+
const tested = new Set();
|
|
67
|
+
|
|
68
|
+
// Check common paths
|
|
69
|
+
for (const path of this.COMMON_ENDPOINTS) {
|
|
70
|
+
const url = `${origin}${path}`;
|
|
71
|
+
if (tested.has(url)) continue;
|
|
72
|
+
tested.add(url);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const response = await this._gqlRequest(url, '{ __typename }');
|
|
76
|
+
if (response?.data?.__typename || response?.errors) {
|
|
77
|
+
endpoints.push(url);
|
|
78
|
+
}
|
|
79
|
+
} catch { /* not GraphQL */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Also detect from surface inventory (look for /graphql in API paths)
|
|
83
|
+
for (const page of (surfaceInventory.pages || [])) {
|
|
84
|
+
const url = page.url;
|
|
85
|
+
if (!url || tested.has(url)) continue;
|
|
86
|
+
if (!/graphql|gql|query/i.test(url)) continue;
|
|
87
|
+
tested.add(url);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const response = await this._gqlRequest(url, '{ __typename }');
|
|
91
|
+
if (response?.data?.__typename || response?.errors) {
|
|
92
|
+
endpoints.push(url);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* not GraphQL */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [...new Set(endpoints)];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async _testIntrospection(endpoint) {
|
|
101
|
+
const introspectionQuery = `{
|
|
102
|
+
__schema {
|
|
103
|
+
types { name kind fields { name type { name kind } } }
|
|
104
|
+
queryType { name }
|
|
105
|
+
mutationType { name }
|
|
106
|
+
}
|
|
107
|
+
}`;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const response = await this._gqlRequest(endpoint, introspectionQuery);
|
|
111
|
+
if (response?.data?.__schema) {
|
|
112
|
+
const types = response.data.__schema.types || [];
|
|
113
|
+
const queryFields = types.find(t => t.name === response.data.__schema.queryType?.name)?.fields || [];
|
|
114
|
+
const hasMutation = !!response.data.__schema.mutationType;
|
|
115
|
+
|
|
116
|
+
return createFinding({
|
|
117
|
+
module: 'logic',
|
|
118
|
+
title: 'GraphQL: Introspection Enabled',
|
|
119
|
+
severity: 'medium',
|
|
120
|
+
affected_surface: endpoint,
|
|
121
|
+
description: `The GraphQL endpoint at ${endpoint} has introspection enabled. Introspection exposes the full schema — all types, fields, queries, and mutations — allowing attackers to enumerate the entire API surface, discover hidden or internal fields, and construct targeted attacks. Found ${queryFields.length} queries${hasMutation ? ' and mutations' : ''}.`,
|
|
122
|
+
reproduction: [
|
|
123
|
+
`1. POST to ${endpoint} with Content-Type: application/json`,
|
|
124
|
+
'2. Body: {"query": "{ __schema { types { name } } }"}',
|
|
125
|
+
'3. Full schema is returned',
|
|
126
|
+
],
|
|
127
|
+
evidence: `Schema returned ${types.length} types. Query fields: ${queryFields.slice(0, 5).map(f => f.name).join(', ')}...`,
|
|
128
|
+
remediation: 'Disable introspection in production. In Apollo Server: introspection: false in production config. In graphql-yoga: use disableIntrospection plugin. Allow introspection only for trusted IP ranges or dev environments.',
|
|
129
|
+
references: ['https://owasp.org/www-project-top-10-for-large-language-model-applications/', 'https://graphql.org/learn/introspection/'],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch { /* not vulnerable */ }
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async _testBatchAmplification(endpoint) {
|
|
137
|
+
// Send 100 identical queries as a batch array
|
|
138
|
+
const batchQuery = Array.from({ length: 100 }, (_, i) => ({
|
|
139
|
+
query: `query q${i} { __typename }`,
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
145
|
+
|
|
146
|
+
const response = await fetch(endpoint, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify(batchQuery),
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
});
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
|
|
154
|
+
if (response.ok) {
|
|
155
|
+
const data = await response.json().catch(() => null);
|
|
156
|
+
if (Array.isArray(data) && data.length >= 10) {
|
|
157
|
+
return createFinding({
|
|
158
|
+
module: 'logic',
|
|
159
|
+
title: 'GraphQL: Batch Query Amplification (DoS Vector)',
|
|
160
|
+
severity: 'high',
|
|
161
|
+
affected_surface: endpoint,
|
|
162
|
+
description: `The GraphQL endpoint at ${endpoint} accepts batched queries and processed ${data.length} queries in a single HTTP request. Without limits, an attacker can amplify a single request into thousands of server-side operations, causing resource exhaustion. For authenticated endpoints, this also bypasses per-IP rate limiting since all 100 queries share one network request.`,
|
|
163
|
+
reproduction: [
|
|
164
|
+
`1. POST to ${endpoint} with a JSON array of queries`,
|
|
165
|
+
`2. Server processed ${data.length} queries in one request`,
|
|
166
|
+
'3. Amplify further with authentication-heavy queries',
|
|
167
|
+
],
|
|
168
|
+
evidence: `Sent 100 batched queries. Response contained ${data.length} results.`,
|
|
169
|
+
remediation: 'Limit batch query size (Apollo: maxBatchSize option). Implement query cost analysis. Rate limit by operation count, not just HTTP request count. Consider disabling batching entirely in production.',
|
|
170
|
+
references: ['https://www.apollographql.com/docs/apollo-server/performance/apq/', 'https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html'],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch { /* not vulnerable or not supported */ }
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async _testDeepNesting(endpoint) {
|
|
179
|
+
// Build a deeply nested query using __type (always available)
|
|
180
|
+
const depth = 10;
|
|
181
|
+
let query = 'query { __type(name: "Query") { ';
|
|
182
|
+
let closing = '';
|
|
183
|
+
for (let i = 0; i < depth; i++) {
|
|
184
|
+
query += 'fields { type { ';
|
|
185
|
+
closing += '} }';
|
|
186
|
+
}
|
|
187
|
+
query += 'name' + closing + ' } }';
|
|
188
|
+
|
|
189
|
+
const start = Date.now();
|
|
190
|
+
try {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
193
|
+
|
|
194
|
+
const response = await this._gqlRequest(endpoint, query, controller);
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
const elapsed = Date.now() - start;
|
|
197
|
+
|
|
198
|
+
// If it succeeds and takes >3s, it's likely not limiting depth
|
|
199
|
+
if (response && !response.errors?.some(e => /depth|complexity|limit/i.test(e.message)) && elapsed > 2000) {
|
|
200
|
+
return createFinding({
|
|
201
|
+
module: 'logic',
|
|
202
|
+
title: 'GraphQL: No Query Depth Limit (DoS Vector)',
|
|
203
|
+
severity: 'medium',
|
|
204
|
+
affected_surface: endpoint,
|
|
205
|
+
description: `The GraphQL endpoint at ${endpoint} processed a ${depth}-level deeply nested query in ${elapsed}ms without rejecting it. Without query depth limits, exponentially nested queries can cause CPU/memory exhaustion on the server, leading to denial of service.`,
|
|
206
|
+
reproduction: [
|
|
207
|
+
`1. Send the nested query (${depth} levels deep) to ${endpoint}`,
|
|
208
|
+
`2. Server processed it in ${elapsed}ms`,
|
|
209
|
+
'3. Increase depth to 50+ levels to cause resource exhaustion',
|
|
210
|
+
],
|
|
211
|
+
evidence: `Query depth: ${depth} levels\nTime to respond: ${elapsed}ms\nNo depth limit error returned`,
|
|
212
|
+
remediation: 'Implement query depth limiting. Apollo Server: use graphql-depth-limit (maxDepth: 7). graphql-yoga: install @escape.tech/graphman and configure depth limit. Also implement query complexity analysis.',
|
|
213
|
+
references: ['https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html'],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
} catch { /* timeout or error — may be protected */ }
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async _testFieldSuggestions(endpoint) {
|
|
221
|
+
// Send a query with a typo to trigger "Did you mean X?" suggestions
|
|
222
|
+
// This reveals field names even without introspection
|
|
223
|
+
try {
|
|
224
|
+
const response = await this._gqlRequest(endpoint, '{ usr { nam emai } }');
|
|
225
|
+
if (!response?.errors) return null;
|
|
226
|
+
|
|
227
|
+
const suggestions = response.errors
|
|
228
|
+
.filter(e => /did you mean|suggestion/i.test(e.message))
|
|
229
|
+
.map(e => e.message);
|
|
230
|
+
|
|
231
|
+
if (suggestions.length > 0) {
|
|
232
|
+
return createFinding({
|
|
233
|
+
module: 'logic',
|
|
234
|
+
title: 'GraphQL: Field Suggestions Leak Schema (Introspection Disabled Bypass)',
|
|
235
|
+
severity: 'low',
|
|
236
|
+
affected_surface: endpoint,
|
|
237
|
+
description: `The GraphQL endpoint at ${endpoint} returns "Did you mean?" suggestions in error messages, even though introspection may be disabled. This allows attackers to enumerate field names by submitting intentional typos and reading the suggestions. Schema enumeration is possible without introspection access.`,
|
|
238
|
+
reproduction: [
|
|
239
|
+
`1. POST to ${endpoint}: {"query": "{ usr { emai } }"}`,
|
|
240
|
+
'2. Server responds with "Did you mean: email?"',
|
|
241
|
+
'3. Repeat with systematic typos to enumerate all fields',
|
|
242
|
+
],
|
|
243
|
+
evidence: suggestions.slice(0, 3).join('\n'),
|
|
244
|
+
remediation: 'Disable field suggestions in production. Apollo Server: set fieldSuggestions: false (Apollo Server 3.6+). Ensure error messages in production are generic and do not leak schema information.',
|
|
245
|
+
references: ['https://www.apollographql.com/docs/apollo-server/security/security/'],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
} catch { /* not GraphQL or no suggestions */ }
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async _testAliasBypass(endpoint) {
|
|
253
|
+
// Use aliases to send 50 operations as a single query (bypasses per-query rate limits)
|
|
254
|
+
const aliases = Array.from({ length: 50 }, (_, i) => `q${i}: __typename`).join('\n');
|
|
255
|
+
const query = `{ ${aliases} }`;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const response = await this._gqlRequest(endpoint, query);
|
|
259
|
+
if (response?.data && Object.keys(response.data).length >= 50) {
|
|
260
|
+
return createFinding({
|
|
261
|
+
module: 'logic',
|
|
262
|
+
title: 'GraphQL: Alias-Based Rate Limit Bypass',
|
|
263
|
+
severity: 'medium',
|
|
264
|
+
affected_surface: endpoint,
|
|
265
|
+
description: `The GraphQL endpoint at ${endpoint} allows a single query to execute 50+ aliased operations. Since rate limiting is typically applied per HTTP request, aliases can be used to bypass rate limits by bundling many operations into one request. For expensive operations (e.g., login, search), this amplifies brute-force capability by 50×.`,
|
|
266
|
+
reproduction: [
|
|
267
|
+
`1. Send a single query with 50 aliased operations to ${endpoint}`,
|
|
268
|
+
`2. All 50 executed (response has ${Object.keys(response.data).length} keys)`,
|
|
269
|
+
'3. For login: 50 password attempts in 1 HTTP request',
|
|
270
|
+
],
|
|
271
|
+
evidence: `Sent 50 aliases in one query. Response had ${Object.keys(response.data).length} data fields.`,
|
|
272
|
+
remediation: 'Implement query complexity scoring that counts aliased fields. Limit the total number of aliases (Apollo: maxAliases). Implement per-operation rate limiting, not just per-request. Use persisted queries to restrict allowed operations.',
|
|
273
|
+
references: ['https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html'],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
} catch { /* not vulnerable */ }
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async _gqlRequest(endpoint, query, controller) {
|
|
281
|
+
if (!controller) {
|
|
282
|
+
controller = new AbortController();
|
|
283
|
+
setTimeout(() => controller.abort(), 12000);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const response = await fetch(endpoint, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
289
|
+
body: JSON.stringify({ query }),
|
|
290
|
+
signal: controller.signal,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!response.ok && response.status !== 400) return null;
|
|
294
|
+
return response.json().catch(() => null);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export default GraphQLAuditor;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ParameterPolluter — Tests for HTTP Parameter Pollution (HPP) vulnerabilities.
|
|
5
|
+
*
|
|
6
|
+
* HPP occurs when an application receives multiple values for the same parameter
|
|
7
|
+
* and uses an unexpected one. This can bypass security checks, WAF rules,
|
|
8
|
+
* or access controls depending on which parameter value the backend actually uses.
|
|
9
|
+
*
|
|
10
|
+
* Attack variants:
|
|
11
|
+
* 1. URL query string duplication: ?user_id=1&user_id=admin
|
|
12
|
+
* 2. Body + Query collision: POST body user_id=1, URL ?user_id=admin
|
|
13
|
+
* 3. Array notation: ?user_id[]=1&user_id[]=admin
|
|
14
|
+
* 4. Verb tampering for bypass: change GET → POST or HEAD
|
|
15
|
+
* 5. Content-Type confusion: send JSON payload as form-encoded
|
|
16
|
+
*/
|
|
17
|
+
export class ParameterPolluter {
|
|
18
|
+
constructor(logger) {
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
|
|
21
|
+
// High-value parameters to test for pollution
|
|
22
|
+
this.SENSITIVE_PARAMS = [
|
|
23
|
+
{ name: 'user_id', testValues: ['0', 'null', 'undefined', '../admin', 'admin', '1 OR 1=1'] },
|
|
24
|
+
{ name: 'role', testValues: ['admin', 'superuser', 'root', 'administrator'] },
|
|
25
|
+
{ name: 'admin', testValues: ['true', '1', 'yes', 'on'] },
|
|
26
|
+
{ name: 'is_admin', testValues: ['true', '1'] },
|
|
27
|
+
{ name: 'account_id', testValues: ['0', '-1', 'null', '../'] },
|
|
28
|
+
{ name: 'token', testValues: ['undefined', 'null', '', '0'] },
|
|
29
|
+
{ name: 'redirect', testValues: ['https://evil.com', '//evil.com', '/\\/evil.com'] },
|
|
30
|
+
{ name: 'callback', testValues: ['alert', 'console.log', 'https://evil.com?'] },
|
|
31
|
+
{ name: 'action', testValues: ['delete', 'admin', 'debug', 'export'] },
|
|
32
|
+
{ name: 'debug', testValues: ['true', '1', 'yes'] },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async pollute(businessContext, surfaceInventory) {
|
|
37
|
+
const findings = [];
|
|
38
|
+
|
|
39
|
+
for (const page of surfaceInventory.pages) {
|
|
40
|
+
if (!page.url || page.status >= 400) continue;
|
|
41
|
+
const url = new URL(page.url);
|
|
42
|
+
const existingParams = [...url.searchParams.keys()];
|
|
43
|
+
|
|
44
|
+
// Test 1: Duplicate existing parameters with escalated values
|
|
45
|
+
for (const param of existingParams) {
|
|
46
|
+
const original = url.searchParams.get(param);
|
|
47
|
+
const result = await this._testDuplication(url, param, original);
|
|
48
|
+
if (result) findings.push(result);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Test 2: Inject sensitive parameters that aren't in the URL
|
|
52
|
+
for (const { name, testValues } of this.SENSITIVE_PARAMS) {
|
|
53
|
+
if (existingParams.includes(name)) continue;
|
|
54
|
+
const result = await this._testInjection(url, name, testValues);
|
|
55
|
+
if (result) findings.push(result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Test 3: HTTP verb tampering on API endpoints
|
|
60
|
+
for (const page of surfaceInventory.pages.slice(0, 15)) {
|
|
61
|
+
const result = await this._testVerbTamper(page.url);
|
|
62
|
+
if (result) findings.push(result);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.logger?.info?.(`Parameter Polluter: found ${findings.length} issues`);
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Test parameter duplication: ?param=original¶m=escalated
|
|
71
|
+
* Measure if behavior changes when the param appears twice.
|
|
72
|
+
*/
|
|
73
|
+
async _testDuplication(url, param, original) {
|
|
74
|
+
try {
|
|
75
|
+
// Baseline request (normal param)
|
|
76
|
+
const baseResponse = await this._fetchWithTimeout(url.toString());
|
|
77
|
+
if (!baseResponse) return null;
|
|
78
|
+
|
|
79
|
+
// Polluted request: append escalated value
|
|
80
|
+
const pollutedUrl = new URL(url.toString());
|
|
81
|
+
// Append a second value by manipulating the string directly
|
|
82
|
+
const pollutedStr = pollutedUrl.toString() + `&${param}=admin&${param}=0&${param}=undefined`;
|
|
83
|
+
|
|
84
|
+
const pollutedResponse = await this._fetchWithTimeout(pollutedStr);
|
|
85
|
+
if (!pollutedResponse) return null;
|
|
86
|
+
|
|
87
|
+
// Compare: different status code or significantly different response size indicates different handling
|
|
88
|
+
const statusDiff = baseResponse.status !== pollutedResponse.status;
|
|
89
|
+
const sizeDiff = Math.abs(baseResponse.body.length - pollutedResponse.body.length) > 200;
|
|
90
|
+
const newPrivileges = /admin|dashboard|user.*list|export|settings/i.test(pollutedResponse.body) &&
|
|
91
|
+
!/admin|dashboard|user.*list|export|settings/i.test(baseResponse.body);
|
|
92
|
+
|
|
93
|
+
if (statusDiff || newPrivileges) {
|
|
94
|
+
return createFinding({
|
|
95
|
+
module: 'logic',
|
|
96
|
+
title: `HTTP Parameter Pollution: "${param}" behavior change with duplicate values`,
|
|
97
|
+
severity: newPrivileges ? 'high' : 'medium',
|
|
98
|
+
affected_surface: url.toString(),
|
|
99
|
+
description: `The parameter "${param}" at ${url.toString()} behaves differently when submitted with duplicate (polluted) values. The baseline response had status ${baseResponse.status}, but the polluted request (with ?${param}=${original}&${param}=admin) returned status ${pollutedResponse.status}. This may indicate that the server uses a different copy of the parameter than expected, potentially bypassing access controls or validation.`,
|
|
100
|
+
reproduction: [
|
|
101
|
+
`1. Baseline: GET ${url.toString()}`,
|
|
102
|
+
`2. Polluted: GET ${pollutedStr}`,
|
|
103
|
+
`3. Response differs: baseline ${baseResponse.status} → polluted ${pollutedResponse.status}`,
|
|
104
|
+
],
|
|
105
|
+
evidence: `Param: ${param}\nBaseline status: ${baseResponse.status}\nPolluted status: ${pollutedResponse.status}\nSize diff: ${Math.abs(baseResponse.body.length - pollutedResponse.body.length)} bytes`,
|
|
106
|
+
remediation: 'Use a strict parameter parsing strategy: reject requests with duplicate parameters, or explicitly define which value to use (first or last). Apply the same strategy consistently across all framework layers (web server, app framework, middleware).',
|
|
107
|
+
references: ['https://owasp.org/www-project-cheat-sheets/cheatsheets/HTTP_Parameter_Pollution_Prevention_Cheat_Sheet.html', 'CWE-235'],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} catch { /* skip */ }
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Test injecting new sensitive parameters into URLs that don't already have them.
|
|
116
|
+
*/
|
|
117
|
+
async _testInjection(url, paramName, testValues) {
|
|
118
|
+
try {
|
|
119
|
+
const baseResponse = await this._fetchWithTimeout(url.toString());
|
|
120
|
+
if (!baseResponse || baseResponse.status >= 400) return null;
|
|
121
|
+
|
|
122
|
+
for (const value of testValues) {
|
|
123
|
+
const testUrl = new URL(url.toString());
|
|
124
|
+
testUrl.searchParams.set(paramName, value);
|
|
125
|
+
|
|
126
|
+
const response = await this._fetchWithTimeout(testUrl.toString());
|
|
127
|
+
if (!response) continue;
|
|
128
|
+
|
|
129
|
+
// Look for clear indicators of privilege change
|
|
130
|
+
const privilegeGranted =
|
|
131
|
+
(response.status < 400 && baseResponse.status >= 400) || // Became accessible
|
|
132
|
+
(/admin|dashboard|panel|settings|debug.*true|is_admin.*true/i.test(response.body) &&
|
|
133
|
+
!/admin|dashboard|panel|settings/i.test(baseResponse.body));
|
|
134
|
+
|
|
135
|
+
if (privilegeGranted) {
|
|
136
|
+
return createFinding({
|
|
137
|
+
module: 'logic',
|
|
138
|
+
title: `Parameter Injection: ?${paramName}=${value} grants elevated access`,
|
|
139
|
+
severity: 'critical',
|
|
140
|
+
affected_surface: url.toString(),
|
|
141
|
+
description: `Adding the parameter "?${paramName}=${value}" to ${url.toString()} appears to grant elevated access or change application behavior in an unexpected way. The server accepted the parameter and changed its response significantly. This suggests the parameter is processed by the backend without proper authorization checks.`,
|
|
142
|
+
reproduction: [
|
|
143
|
+
`1. Baseline: GET ${url.toString()} — status ${baseResponse.status}`,
|
|
144
|
+
`2. Injected: GET ${testUrl.toString()} — status ${response.status}`,
|
|
145
|
+
'3. Application behavior changed',
|
|
146
|
+
],
|
|
147
|
+
evidence: `Injected: ?${paramName}=${value}\nBaseline status: ${baseResponse.status}\nInjected status: ${response.status}`,
|
|
148
|
+
remediation: 'Never use query parameters alone to determine authorization or privileges. All access control decisions must be based on server-side session state and validated credentials, never on client-supplied parameters.',
|
|
149
|
+
references: ['CWE-235', 'https://owasp.org/www-project-top-ten/'],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch { /* skip */ }
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Test HTTP verb tampering — some apps restrict DELETE but allow POST with ?_method=DELETE.
|
|
159
|
+
*/
|
|
160
|
+
async _testVerbTamper(url) {
|
|
161
|
+
if (!url) return null;
|
|
162
|
+
try {
|
|
163
|
+
const methods = ['DELETE', 'PUT', 'PATCH'];
|
|
164
|
+
const baseGet = await this._fetchWithTimeout(url.toString(), 'GET');
|
|
165
|
+
if (!baseGet) return null;
|
|
166
|
+
|
|
167
|
+
for (const method of methods) {
|
|
168
|
+
const response = await this._fetchWithTimeout(url.toString(), method);
|
|
169
|
+
if (!response) continue;
|
|
170
|
+
|
|
171
|
+
// If DELETE/PUT succeeds (2xx) on a page that GETs normally, flag it
|
|
172
|
+
if (response.status < 300 && baseGet.status < 400) {
|
|
173
|
+
return createFinding({
|
|
174
|
+
module: 'logic',
|
|
175
|
+
title: `HTTP Verb Tampering: ${method} accepted at ${new URL(url).pathname}`,
|
|
176
|
+
severity: 'high',
|
|
177
|
+
affected_surface: url,
|
|
178
|
+
description: `The endpoint ${url} accepts ${method} requests without apparent authorization restrictions. If this endpoint supports state-changing operations via ${method}, it may be exploitable without CSRF protection (since CORS does protect non-simple methods, but SPA apps with CORS misconfiguration may still be vulnerable).`,
|
|
179
|
+
reproduction: [
|
|
180
|
+
`1. Send ${method} ${url}`,
|
|
181
|
+
`2. Server responds with ${response.status} (success)`,
|
|
182
|
+
],
|
|
183
|
+
evidence: `GET status: ${baseGet.status}\n${method} status: ${response.status}`,
|
|
184
|
+
remediation: 'Implement strict HTTP method validation. Return 405 Method Not Allowed for unsupported verbs on each endpoint. Use server framework routing to explicitly define allowed methods per route.',
|
|
185
|
+
references: ['https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/06-Test_HTTP_Methods'],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch { /* skip */ }
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async _fetchWithTimeout(url, method = 'GET') {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
196
|
+
try {
|
|
197
|
+
const response = await fetch(url, {
|
|
198
|
+
method,
|
|
199
|
+
redirect: 'manual',
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
});
|
|
202
|
+
const body = await response.text().catch(() => '');
|
|
203
|
+
return { status: response.status, body };
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
} finally {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export default ParameterPolluter;
|