@sun-asterisk/sunlint 1.3.26 → 1.3.28
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/config/rules/enhanced-rules-registry.json +101 -17
- package/config/rules/rules-registry-generated.json +22 -22
- package/origin-rules/security-en.md +351 -338
- package/package.json +1 -1
- package/rules/common/C003_no_vague_abbreviations/analyzer.js +73 -21
- package/rules/common/C017_constructor_logic/symbol-based-analyzer.js +206 -2
- package/rules/common/C024_no_scatter_hardcoded_constants/symbol-based-analyzer.js +553 -58
- package/rules/common/C029_catch_block_logging/analyzer.js +47 -12
- package/rules/common/C033_separate_service_repository/symbol-based-analyzer.js +35 -15
- package/rules/common/C041_no_sensitive_hardcode/symbol-based-analyzer.js +9 -5
- package/rules/security/S003_open_redirect_protection/README.md +371 -0
- package/rules/security/S003_open_redirect_protection/analyzer.js +135 -0
- package/rules/security/S003_open_redirect_protection/config.json +58 -0
- package/rules/security/S003_open_redirect_protection/symbol-based-analyzer.js +884 -0
- package/rules/security/S004_sensitive_data_logging/analyzer.js +135 -0
- package/rules/security/S004_sensitive_data_logging/config.json +62 -0
- package/rules/security/S004_sensitive_data_logging/symbol-based-analyzer.js +592 -0
- package/rules/security/S005_no_origin_auth/analyzer.js +97 -148
- package/rules/security/S005_no_origin_auth/config.json +28 -67
- package/rules/security/S005_no_origin_auth/symbol-based-analyzer.js +708 -0
- package/rules/security/S006_no_plaintext_recovery_codes/symbol-based-analyzer.js +170 -31
- package/rules/security/S010_no_insecure_encryption/analyzer.js +8 -2
- package/rules/security/S012_hardcoded_secrets/analyzer.js +149 -0
- package/rules/security/S012_hardcoded_secrets/config.json +75 -0
- package/rules/security/S012_hardcoded_secrets/symbol-based-analyzer.js +1204 -0
- package/rules/security/S013_tls_enforcement/symbol-based-analyzer.js +87 -0
- package/rules/security/S017_use_parameterized_queries/analyzer.js +11 -78
- package/rules/security/S017_use_parameterized_queries/symbol-based-analyzer.js +1146 -1
- package/rules/security/S019_smtp_injection_protection/analyzer.js +120 -0
- package/rules/security/S019_smtp_injection_protection/config.json +35 -0
- package/rules/security/S019_smtp_injection_protection/symbol-based-analyzer.js +687 -0
- package/rules/security/S020_no_eval_dynamic_code/analyzer.js +55 -130
- package/rules/security/S020_no_eval_dynamic_code/symbol-based-analyzer.js +4 -19
- package/rules/security/S022_escape_output_context/README.md +254 -0
- package/rules/security/S022_escape_output_context/analyzer.js +510 -0
- package/rules/security/S022_escape_output_context/config.json +229 -0
- package/rules/security/S023_no_json_injection/analyzer.js +15 -0
- package/rules/security/S023_no_json_injection/ast-analyzer.js +18 -3
- package/rules/security/S023_no_json_injection/config.json +133 -0
- package/rules/security/S024_xpath_xxe_protection/regex-based-analyzer.js +41 -0
- package/rules/security/S027_no_hardcoded_secrets/analyzer.js +67 -8
- package/rules/security/S027_no_hardcoded_secrets/categorized-analyzer.js +29 -6
- package/rules/security/S029_csrf_protection/config.json +127 -0
- package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +160 -28
- package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +81 -19
- package/rules/security/S031_secure_session_cookies/analyzer.js +20 -2
- package/rules/security/S031_secure_session_cookies/regex-based-analyzer.js +100 -0
- package/rules/security/S031_secure_session_cookies/symbol-based-analyzer.js +8 -1
- package/rules/security/S032_httponly_session_cookies/analyzer.js +2 -2
- package/rules/security/S032_httponly_session_cookies/regex-based-analyzer.js +115 -0
- package/rules/security/S032_httponly_session_cookies/symbol-based-analyzer.js +39 -10
- package/rules/security/S036_lfi_rfi_protection/analyzer.js +224 -0
- package/rules/security/S036_lfi_rfi_protection/config.json +20 -0
- package/rules/security/S040_session_fixation_protection/analyzer.js +153 -0
- package/rules/security/S040_session_fixation_protection/config.json +20 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/README.md +83 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/analyzer.js +153 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/config.json +41 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/symbol-based-analyzer.js +1139 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/README.md +107 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/analyzer.js +153 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/config.json +41 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/symbol-based-analyzer.js +541 -0
- package/docs/COMMAND-EXAMPLES.md +0 -390
- package/docs/FILE_LIMITS_COMPLETION_REPORT.md +0 -151
- package/docs/FOLDER_STRUCTURE.md +0 -59
- package/docs/SIMPLIFIED_USAGE_GUIDE.md +0 -208
- package/rules/security/S017_use_parameterized_queries/regex-based-analyzer.js +0 -541
- package/rules/security/S020_no_eval_dynamic_code/regex-based-analyzer.js +0 -307
|
@@ -215,14 +215,18 @@ class C029Analyzer {
|
|
|
215
215
|
} else {
|
|
216
216
|
// No logging found - check if there's valid error handling
|
|
217
217
|
const hasErrorHandling = this.hasValidErrorHandling(block, exceptionVar);
|
|
218
|
-
|
|
219
|
-
if
|
|
218
|
+
|
|
219
|
+
// Skip if underscore variable (intentional ignore) AND has error handling
|
|
220
|
+
const isIntentionallyIgnored = exceptionVar && exceptionVar.startsWith('_');
|
|
221
|
+
const shouldSkip = (isIntentionallyIgnored && hasErrorHandling) || this.isTestFile(filePath);
|
|
222
|
+
|
|
223
|
+
if (!hasErrorHandling && !shouldSkip) {
|
|
220
224
|
violations.push(this.createViolation(
|
|
221
225
|
filePath,
|
|
222
226
|
startLine,
|
|
223
227
|
startColumn,
|
|
224
228
|
'no_logging',
|
|
225
|
-
exceptionVar
|
|
229
|
+
exceptionVar
|
|
226
230
|
? `Catch block does not log exception '${exceptionVar}'`
|
|
227
231
|
: 'Catch block does not log exception',
|
|
228
232
|
'Add console.error() or logger.error() with error details',
|
|
@@ -232,7 +236,10 @@ class C029Analyzer {
|
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
// STAGE 4: Check for unused exception variable
|
|
235
|
-
if
|
|
239
|
+
// Skip if variable name starts with underscore (conventional way to indicate intentional ignore)
|
|
240
|
+
const isIntentionallyIgnored = exceptionVar && exceptionVar.startsWith('_');
|
|
241
|
+
|
|
242
|
+
if (exceptionVar && !isIntentionallyIgnored && !this.isExceptionUsed(block, exceptionVar) && !this.hasExplicitIgnoreComment(catchClause)) {
|
|
236
243
|
violations.push(this.createViolation(
|
|
237
244
|
filePath,
|
|
238
245
|
startLine,
|
|
@@ -277,7 +284,8 @@ class C029Analyzer {
|
|
|
277
284
|
usesExceptionVar: false,
|
|
278
285
|
logLevels: [],
|
|
279
286
|
hasStackTrace: false,
|
|
280
|
-
hasContextData: false
|
|
287
|
+
hasContextData: false,
|
|
288
|
+
loggingExpressions: [] // Track actual expressions used
|
|
281
289
|
};
|
|
282
290
|
|
|
283
291
|
// Find all call expressions
|
|
@@ -333,6 +341,9 @@ class C029Analyzer {
|
|
|
333
341
|
arguments: argsText,
|
|
334
342
|
line: call.getStartLineNumber()
|
|
335
343
|
});
|
|
344
|
+
|
|
345
|
+
// Track expression for better error messages
|
|
346
|
+
loggingInfo.loggingExpressions.push(expressionText);
|
|
336
347
|
}
|
|
337
348
|
}
|
|
338
349
|
|
|
@@ -367,21 +378,32 @@ class C029Analyzer {
|
|
|
367
378
|
const issues = [];
|
|
368
379
|
|
|
369
380
|
// Issue 1: Using inappropriate log level (log/info/debug instead of error/warn)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
381
|
+
// BUT only for console.xxx, not for logger.xxx (custom loggers may have different semantics)
|
|
382
|
+
const hasInappropriateConsoleLevel = loggingInfo.calls.some(call => {
|
|
383
|
+
const isConsole = call.expression.startsWith('console.');
|
|
384
|
+
const isInappropriateLevel = this.config.inappropriateLevels.includes(call.level);
|
|
385
|
+
return isConsole && isInappropriateLevel;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
if (hasInappropriateConsoleLevel && !this.isTestFile(filePath)) {
|
|
389
|
+
// Get only console calls with inappropriate levels
|
|
390
|
+
const consoleLevels = loggingInfo.calls
|
|
391
|
+
.filter(c => c.expression.startsWith('console.') && this.config.inappropriateLevels.includes(c.level))
|
|
392
|
+
.map(c => c.level);
|
|
373
393
|
|
|
374
|
-
if (hasInappropriateLevel && !this.isTestFile(filePath)) {
|
|
375
394
|
issues.push({
|
|
376
395
|
type: 'inappropriate_log_level',
|
|
377
|
-
message: `Catch block uses console.${
|
|
396
|
+
message: `Catch block uses console.${[...new Set(consoleLevels)].join('/')} instead of console.error or console.warn`,
|
|
378
397
|
suggestion: 'Use console.error() or console.warn() for error logging in catch blocks',
|
|
379
398
|
confidence: 0.75
|
|
380
399
|
});
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
// Issue 2: Exception variable not included in logging
|
|
384
|
-
if (
|
|
403
|
+
// Skip if variable is underscore (intentional ignore - developer explicitly doesn't want error details)
|
|
404
|
+
const isIntentionallyIgnored = exceptionVar && exceptionVar.startsWith('_');
|
|
405
|
+
|
|
406
|
+
if (exceptionVar && !isIntentionallyIgnored && !loggingInfo.usesExceptionVar) {
|
|
385
407
|
issues.push({
|
|
386
408
|
type: 'exception_not_logged',
|
|
387
409
|
message: `Exception variable '${exceptionVar}' is not included in logging`,
|
|
@@ -462,7 +484,20 @@ class C029Analyzer {
|
|
|
462
484
|
/utils?\.log/i,
|
|
463
485
|
/helpers?\.log/i,
|
|
464
486
|
/ErrorUtils/,
|
|
465
|
-
/ErrorService
|
|
487
|
+
/ErrorService/,
|
|
488
|
+
// UI Feedback patterns (toast, notification, alert, etc.)
|
|
489
|
+
/toast\.(error|failed|show|warning)\s*\(/,
|
|
490
|
+
/notification\.(error|show|warning)\s*\(/,
|
|
491
|
+
/message\.(error|show|warning)\s*\(/,
|
|
492
|
+
/alert\s*\(/,
|
|
493
|
+
/showError\s*\(/,
|
|
494
|
+
/showMessage\s*\(/,
|
|
495
|
+
// Fallback/recovery actions
|
|
496
|
+
/window\.open\s*\(/,
|
|
497
|
+
/window\.location/,
|
|
498
|
+
/navigate\s*\(/,
|
|
499
|
+
/redirect\s*\(/,
|
|
500
|
+
/router\.(push|replace)\s*\(/
|
|
466
501
|
];
|
|
467
502
|
|
|
468
503
|
if (errorHandlerPatterns.some(pattern => pattern.test(text))) {
|
|
@@ -768,32 +768,52 @@ class C033SymbolBasedAnalyzer {
|
|
|
768
768
|
analyzeControllerFile(sourceFile, filePath) {
|
|
769
769
|
const violations = [];
|
|
770
770
|
const classes = sourceFile.getClasses();
|
|
771
|
-
|
|
771
|
+
|
|
772
772
|
for (const cls of classes) {
|
|
773
773
|
const className = cls.getName() || 'UnnamedClass';
|
|
774
774
|
const methods = cls.getMethods();
|
|
775
|
-
|
|
775
|
+
|
|
776
776
|
for (const method of methods) {
|
|
777
777
|
const methodBody = method.getBodyText() || '';
|
|
778
|
-
|
|
778
|
+
|
|
779
779
|
// Controllers should not directly access database
|
|
780
780
|
for (const operation of this.databaseOperations) {
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
781
|
+
const operationPattern = new RegExp(`\\b${operation}\\s*\\(`, 'gi');
|
|
782
|
+
const matches = methodBody.matchAll(operationPattern);
|
|
783
|
+
|
|
784
|
+
for (const match of matches) {
|
|
785
|
+
const matchIndex = match.index;
|
|
786
|
+
const contextStart = Math.max(0, matchIndex - 50);
|
|
787
|
+
const context = methodBody.substring(contextStart, matchIndex);
|
|
788
|
+
|
|
789
|
+
// Check if this is a call through service (ALLOWED)
|
|
790
|
+
// Matches: this.xxxService.method(), this.xxxUseCase.method(), this.xxxHandler.method()
|
|
791
|
+
const serviceCallPattern = /this\.([\w]+Service|[\w]+UseCase|[\w]+Handler)\s*\.\s*$/i;
|
|
792
|
+
if (serviceCallPattern.test(context)) {
|
|
793
|
+
continue; // Skip - this is allowed
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Check if this is direct database call (VIOLATION)
|
|
797
|
+
// Matches: this.repository, this.userRepository, this.entityManager, this.xxxClient, etc.
|
|
798
|
+
const directDbCallPattern = /this\.([\w]*Repository|[\w]*Repo|dataSource|connection|entityManager|manager|database|db|[\w]*Client|prisma)\./i;
|
|
799
|
+
const globalDbCallPattern = /(getRepository|getConnection|getManager|createQueryBuilder)\s*\([^)]*\)\s*\.?[\w.]*$/i;
|
|
800
|
+
|
|
801
|
+
if (directDbCallPattern.test(context) || globalDbCallPattern.test(context)) {
|
|
802
|
+
violations.push({
|
|
803
|
+
ruleId: this.ruleId,
|
|
804
|
+
severity: 'warning',
|
|
805
|
+
message: `Controller class '${className}' should not directly access database with '${operation}'. Use Service layer instead.`,
|
|
806
|
+
file: filePath,
|
|
807
|
+
line: method.getStartLineNumber(),
|
|
808
|
+
column: 1
|
|
809
|
+
});
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
792
812
|
}
|
|
793
813
|
}
|
|
794
814
|
}
|
|
795
815
|
}
|
|
796
|
-
|
|
816
|
+
|
|
797
817
|
return violations;
|
|
798
818
|
}
|
|
799
819
|
}
|
|
@@ -65,14 +65,19 @@ class C041SymbolBasedAnalyzer {
|
|
|
65
65
|
/secret/i,
|
|
66
66
|
/password/i,
|
|
67
67
|
/passwd/i,
|
|
68
|
-
/
|
|
68
|
+
/\bpass\b/i,
|
|
69
69
|
/token/i,
|
|
70
|
-
/
|
|
71
|
-
/
|
|
70
|
+
/\b(jwt|bearer)_?token/i,
|
|
71
|
+
/\b(session|csrf)_?token/i,
|
|
72
|
+
/\boauth/i, // OAuth tokens/creds
|
|
73
|
+
/\bauth(_?(key|token|secret|code))\b/i, // auth+credential
|
|
74
|
+
/\bcredential/i,
|
|
72
75
|
/private[_-]?key/i,
|
|
73
76
|
/access[_-]?key/i,
|
|
74
77
|
/client[_-]?secret/i,
|
|
75
|
-
/encryption[_-]?key/i
|
|
78
|
+
/encryption[_-]?key/i,
|
|
79
|
+
/signing[_-]?key/i,
|
|
80
|
+
/webhook[_-]?secret/i
|
|
76
81
|
];
|
|
77
82
|
|
|
78
83
|
// === Safe patterns to exclude (env vars, config imports) ===
|
|
@@ -365,7 +370,6 @@ class C041SymbolBasedAnalyzer {
|
|
|
365
370
|
}
|
|
366
371
|
|
|
367
372
|
const isSensitiveName = this.sensitiveVariableNames.some(pattern => pattern.test(name));
|
|
368
|
-
|
|
369
373
|
if (isSensitiveName) {
|
|
370
374
|
const initText = initializer.getText();
|
|
371
375
|
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# S003 - Open Redirect Protection
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Quy tắc này phát hiện lỗ hổng Open Redirect - nơi ứng dụng chuyển hướng người dùng đến URL được cung cấp từ đầu vào mà không xác thực. Kẻ tấn công có thể lợi dụng để chuyển hướng nạn nhân đến trang độc hại (phishing, malware) thông qua link có vẻ hợp lệ.
|
|
6
|
+
|
|
7
|
+
## OWASP Classification
|
|
8
|
+
|
|
9
|
+
- **Category**: A03:2021 - Injection
|
|
10
|
+
- **CWE**: CWE-601 - URL Redirection to Untrusted Site ('Open Redirect')
|
|
11
|
+
- **Severity**: Warning
|
|
12
|
+
- **Impact**: Medium (Phishing attacks, credential theft, malware distribution)
|
|
13
|
+
|
|
14
|
+
## Vấn đề
|
|
15
|
+
|
|
16
|
+
Khi ứng dụng chuyển hướng người dùng đến URL từ đầu vào mà không xác thực:
|
|
17
|
+
|
|
18
|
+
1. **Phishing attacks**: Kẻ tấn công có thể tạo link hợp lệ dẫn đến trang giả mạo
|
|
19
|
+
2. **Credential theft**: Người dùng tin tưởng domain gốc và nhập thông tin nhạy cảm
|
|
20
|
+
3. **Malware distribution**: Chuyển hướng đến trang chứa malware
|
|
21
|
+
4. **Bypass security controls**: Vượt qua whitelist/blacklist dựa trên domain
|
|
22
|
+
|
|
23
|
+
## Các trường hợp vi phạm
|
|
24
|
+
|
|
25
|
+
### 1. Redirect trực tiếp từ query parameter
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
// ❌ Vi phạm - Redirect trực tiếp không kiểm tra
|
|
29
|
+
app.get('/redirect', (req, res) => {
|
|
30
|
+
const url = req.query.url;
|
|
31
|
+
res.redirect(url); // Nguy hiểm!
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ❌ Vi phạm - Express redirect
|
|
35
|
+
router.get('/goto', (req, res) => {
|
|
36
|
+
res.redirect(req.query.redirect_url);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ❌ Vi phạm - Location header
|
|
40
|
+
app.get('/forward', (req, res) => {
|
|
41
|
+
res.setHeader('Location', req.query.next);
|
|
42
|
+
res.status(302).send();
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Client-side redirect không validate
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
// ❌ Vi phạm - window.location redirect
|
|
50
|
+
const redirectUrl = new URLSearchParams(window.location.search).get('redirect');
|
|
51
|
+
window.location = redirectUrl;
|
|
52
|
+
|
|
53
|
+
// ❌ Vi phạm - window.location.href
|
|
54
|
+
const next = getQueryParam('next');
|
|
55
|
+
window.location.href = next;
|
|
56
|
+
|
|
57
|
+
// ❌ Vi phạm - window.location.replace
|
|
58
|
+
window.location.replace(req.params.url);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. NestJS redirects
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// ❌ Vi phạm - NestJS @Redirect decorator
|
|
65
|
+
@Controller('redirect')
|
|
66
|
+
export class RedirectController {
|
|
67
|
+
@Get()
|
|
68
|
+
@Redirect()
|
|
69
|
+
redirect(@Query('url') url: string) {
|
|
70
|
+
return { url }; // Nguy hiểm!
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ❌ Vi phạm - NestJS res.redirect
|
|
75
|
+
@Get()
|
|
76
|
+
goto(@Query('target') target: string, @Res() res: Response) {
|
|
77
|
+
res.redirect(target);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 4. Next.js redirects (App Router)
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// ❌ Vi phạm - Next.js redirect
|
|
85
|
+
import { redirect } from 'next/navigation';
|
|
86
|
+
|
|
87
|
+
export default async function Page({ searchParams }) {
|
|
88
|
+
redirect(searchParams.url); // Nguy hiểm!
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ❌ Vi phạm - Next.js permanentRedirect
|
|
92
|
+
import { permanentRedirect } from 'next/navigation';
|
|
93
|
+
|
|
94
|
+
export default function RedirectPage({ searchParams }) {
|
|
95
|
+
permanentRedirect(searchParams.target);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ❌ Vi phạm - Next.js router.push
|
|
99
|
+
'use client';
|
|
100
|
+
export default function ClientRedirect() {
|
|
101
|
+
const router = useRouter();
|
|
102
|
+
const searchParams = useSearchParams();
|
|
103
|
+
|
|
104
|
+
const url = searchParams.get('url');
|
|
105
|
+
router.push(url); // Nguy hiểm!
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 5. Nuxt.js redirects
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// ❌ Vi phạm - Nuxt.js navigateTo
|
|
113
|
+
export default defineComponent({
|
|
114
|
+
setup() {
|
|
115
|
+
const route = useRoute();
|
|
116
|
+
const url = route.query.url;
|
|
117
|
+
navigateTo(url); // Nguy hiểm!
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ❌ Vi phạm - Nuxt.js sendRedirect
|
|
122
|
+
export default defineEventHandler((event) => {
|
|
123
|
+
const { url } = getQuery(event);
|
|
124
|
+
return sendRedirect(event, url);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 6. Spring Boot/Java redirects
|
|
129
|
+
|
|
130
|
+
```java
|
|
131
|
+
// ❌ Vi phạm - Spring RedirectView
|
|
132
|
+
@GetMapping("/redirect")
|
|
133
|
+
public RedirectView redirect(@RequestParam String url) {
|
|
134
|
+
return new RedirectView(url);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ❌ Vi phạm - sendRedirect
|
|
138
|
+
@GetMapping("/forward")
|
|
139
|
+
public void forward(@RequestParam String target, HttpServletResponse response) {
|
|
140
|
+
response.sendRedirect(target);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Giải pháp an toàn
|
|
145
|
+
|
|
146
|
+
### 1. Sử dụng Allow List (Whitelist)
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
// ✅ An toàn - Allow list
|
|
150
|
+
const ALLOWED_DOMAINS = [
|
|
151
|
+
'https://example.com',
|
|
152
|
+
'https://app.example.com'
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
app.get('/redirect', (req, res) => {
|
|
156
|
+
const url = req.query.url;
|
|
157
|
+
if (!ALLOWED_DOMAINS.includes(url)) {
|
|
158
|
+
return res.status(400).send('Invalid redirect URL');
|
|
159
|
+
}
|
|
160
|
+
res.redirect(url);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ✅ An toàn - Domain validation
|
|
164
|
+
function isAllowedUrl(urlString) {
|
|
165
|
+
try {
|
|
166
|
+
const url = new URL(urlString);
|
|
167
|
+
const allowedHosts = ['example.com', 'app.example.com'];
|
|
168
|
+
return allowedHosts.includes(url.hostname);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 2. Relative URL only
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
// ✅ An toàn - Chỉ cho phép relative URLs
|
|
179
|
+
function isRelativeUrl(url) {
|
|
180
|
+
return url && url.startsWith('/') && !url.startsWith('//');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
app.get('/redirect', (req, res) => {
|
|
184
|
+
const path = req.query.next;
|
|
185
|
+
if (!isRelativeUrl(path)) {
|
|
186
|
+
return res.status(400).send('Only relative URLs allowed');
|
|
187
|
+
}
|
|
188
|
+
res.redirect(path);
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 3. Indirect redirect mapping
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
// ✅ An toàn - Mapping key
|
|
196
|
+
const REDIRECT_MAP = {
|
|
197
|
+
'home': '/',
|
|
198
|
+
'dashboard': '/dashboard',
|
|
199
|
+
'profile': '/user/profile'
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
app.get('/redirect', (req, res) => {
|
|
203
|
+
const key = req.query.destination;
|
|
204
|
+
const url = REDIRECT_MAP[key];
|
|
205
|
+
if (!url) {
|
|
206
|
+
return res.status(400).send('Invalid destination');
|
|
207
|
+
}
|
|
208
|
+
res.redirect(url);
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### 4. Framework-specific solutions
|
|
213
|
+
|
|
214
|
+
#### NestJS
|
|
215
|
+
```typescript
|
|
216
|
+
// ✅ An toàn - NestJS với DTO validation
|
|
217
|
+
import { IsIn } from 'class-validator';
|
|
218
|
+
|
|
219
|
+
class RedirectDto {
|
|
220
|
+
@IsIn(['home', 'dashboard', 'profile'])
|
|
221
|
+
destination: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Controller('redirect')
|
|
225
|
+
export class SafeController {
|
|
226
|
+
@Get()
|
|
227
|
+
redirect(@Query() query: RedirectDto) {
|
|
228
|
+
const urlMap = {
|
|
229
|
+
home: '/',
|
|
230
|
+
dashboard: '/dashboard',
|
|
231
|
+
profile: '/profile'
|
|
232
|
+
};
|
|
233
|
+
return { url: urlMap[query.destination] }; // Safe
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ✅ An toàn - NestJS với custom validation
|
|
238
|
+
@Controller('goto')
|
|
239
|
+
export class ValidatedController {
|
|
240
|
+
private readonly allowedUrls = ['https://example.com'];
|
|
241
|
+
|
|
242
|
+
@Get()
|
|
243
|
+
goto(@Query('url') url: string, @Res() res: Response) {
|
|
244
|
+
if (this.allowedUrls.includes(url)) {
|
|
245
|
+
res.redirect(url); // Safe
|
|
246
|
+
} else {
|
|
247
|
+
throw new BadRequestException('Invalid URL');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Next.js (App Router)
|
|
254
|
+
```typescript
|
|
255
|
+
// ✅ An toàn - Next.js với allowlist
|
|
256
|
+
const ALLOWED_PATHS = ['/home', '/dashboard', '/profile'];
|
|
257
|
+
|
|
258
|
+
export default function SafeRedirect({ searchParams }) {
|
|
259
|
+
const path = searchParams.path;
|
|
260
|
+
|
|
261
|
+
if (ALLOWED_PATHS.includes(path)) {
|
|
262
|
+
redirect(path); // Safe
|
|
263
|
+
} else {
|
|
264
|
+
redirect('/'); // Safe default
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ✅ An toàn - Next.js với mapping
|
|
269
|
+
const REDIRECT_MAP = {
|
|
270
|
+
'success': '/success',
|
|
271
|
+
'error': '/error'
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default function MappedRedirect({ searchParams }) {
|
|
275
|
+
const key = searchParams.destination;
|
|
276
|
+
redirect(REDIRECT_MAP[key] || '/'); // Safe
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ✅ An toàn - Next.js router với validation
|
|
280
|
+
'use client';
|
|
281
|
+
export default function SafeClientRedirect() {
|
|
282
|
+
const router = useRouter();
|
|
283
|
+
const searchParams = useSearchParams();
|
|
284
|
+
|
|
285
|
+
const handleRedirect = () => {
|
|
286
|
+
const path = searchParams.get('path');
|
|
287
|
+
const safePaths = ['/home', '/about'];
|
|
288
|
+
|
|
289
|
+
if (safePaths.includes(path)) {
|
|
290
|
+
router.push(path); // Safe
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### Nuxt.js
|
|
297
|
+
```typescript
|
|
298
|
+
// ✅ An toàn - Nuxt.js với validation
|
|
299
|
+
const ALLOWED_PATHS = ['/home', '/dashboard'];
|
|
300
|
+
|
|
301
|
+
export default defineEventHandler((event) => {
|
|
302
|
+
const { path } = getQuery(event);
|
|
303
|
+
|
|
304
|
+
if (ALLOWED_PATHS.includes(path)) {
|
|
305
|
+
return navigateTo(path); // Safe
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return navigateTo('/'); // Safe default
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ✅ An toàn - Nuxt.js với mapping
|
|
312
|
+
const ROUTE_MAP = {
|
|
313
|
+
'profile': '/user/profile',
|
|
314
|
+
'settings': '/settings'
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export default defineComponent({
|
|
318
|
+
setup() {
|
|
319
|
+
const route = useRoute();
|
|
320
|
+
|
|
321
|
+
const redirect = () => {
|
|
322
|
+
const key = route.query.destination;
|
|
323
|
+
const url = ROUTE_MAP[key] || '/';
|
|
324
|
+
navigateTo(url); // Safe
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ✅ An toàn - Nuxt.js với domain validation
|
|
330
|
+
export default defineEventHandler((event) => {
|
|
331
|
+
const { url } = getQuery(event);
|
|
332
|
+
const allowedDomains = ['example.com'];
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const parsed = new URL(url);
|
|
336
|
+
if (allowedDomains.includes(parsed.hostname)) {
|
|
337
|
+
return sendRedirect(event, url); // Safe
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
throw createError({ statusCode: 400 });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Phương pháp phát hiện
|
|
346
|
+
|
|
347
|
+
Rule này sử dụng symbol-based analysis để phát hiện:
|
|
348
|
+
|
|
349
|
+
1. **Redirect functions**:
|
|
350
|
+
- Express: `res.redirect()`
|
|
351
|
+
- NestJS: `@Redirect()`, `res.redirect()`
|
|
352
|
+
- Next.js: `redirect()`, `permanentRedirect()`, `router.push()`
|
|
353
|
+
- Nuxt.js: `navigateTo()`, `sendRedirect()`
|
|
354
|
+
- Spring: `response.sendRedirect()`
|
|
355
|
+
- Generic: `window.location`, `setHeader('Location')`
|
|
356
|
+
|
|
357
|
+
2. **User input sources**:
|
|
358
|
+
- `req.query`, `req.params`, `req.body`
|
|
359
|
+
- `@Query()`, `@Param()`, `@Body()` (NestJS)
|
|
360
|
+
- `searchParams`, `useSearchParams()` (Next.js)
|
|
361
|
+
- `useRoute()`, `getQuery()`, `event.query` (Nuxt.js)
|
|
362
|
+
- `URLSearchParams.get()`
|
|
363
|
+
|
|
364
|
+
3. **Dataflow analysis**: Track từ user input đến redirect function
|
|
365
|
+
4. **Validation check**: Kiểm tra có whitelist/validation hay không
|
|
366
|
+
|
|
367
|
+
## Tài liệu tham khảo
|
|
368
|
+
|
|
369
|
+
- [OWASP A03:2021 - Injection](https://owasp.org/Top10/A03_2021-Injection/)
|
|
370
|
+
- [CWE-601: URL Redirection to Untrusted Site](https://cwe.mitre.org/data/definitions/601.html)
|
|
371
|
+
- [OWASP Unvalidated Redirects Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html)
|