@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.
Files changed (69) hide show
  1. package/config/rules/enhanced-rules-registry.json +101 -17
  2. package/config/rules/rules-registry-generated.json +22 -22
  3. package/origin-rules/security-en.md +351 -338
  4. package/package.json +1 -1
  5. package/rules/common/C003_no_vague_abbreviations/analyzer.js +73 -21
  6. package/rules/common/C017_constructor_logic/symbol-based-analyzer.js +206 -2
  7. package/rules/common/C024_no_scatter_hardcoded_constants/symbol-based-analyzer.js +553 -58
  8. package/rules/common/C029_catch_block_logging/analyzer.js +47 -12
  9. package/rules/common/C033_separate_service_repository/symbol-based-analyzer.js +35 -15
  10. package/rules/common/C041_no_sensitive_hardcode/symbol-based-analyzer.js +9 -5
  11. package/rules/security/S003_open_redirect_protection/README.md +371 -0
  12. package/rules/security/S003_open_redirect_protection/analyzer.js +135 -0
  13. package/rules/security/S003_open_redirect_protection/config.json +58 -0
  14. package/rules/security/S003_open_redirect_protection/symbol-based-analyzer.js +884 -0
  15. package/rules/security/S004_sensitive_data_logging/analyzer.js +135 -0
  16. package/rules/security/S004_sensitive_data_logging/config.json +62 -0
  17. package/rules/security/S004_sensitive_data_logging/symbol-based-analyzer.js +592 -0
  18. package/rules/security/S005_no_origin_auth/analyzer.js +97 -148
  19. package/rules/security/S005_no_origin_auth/config.json +28 -67
  20. package/rules/security/S005_no_origin_auth/symbol-based-analyzer.js +708 -0
  21. package/rules/security/S006_no_plaintext_recovery_codes/symbol-based-analyzer.js +170 -31
  22. package/rules/security/S010_no_insecure_encryption/analyzer.js +8 -2
  23. package/rules/security/S012_hardcoded_secrets/analyzer.js +149 -0
  24. package/rules/security/S012_hardcoded_secrets/config.json +75 -0
  25. package/rules/security/S012_hardcoded_secrets/symbol-based-analyzer.js +1204 -0
  26. package/rules/security/S013_tls_enforcement/symbol-based-analyzer.js +87 -0
  27. package/rules/security/S017_use_parameterized_queries/analyzer.js +11 -78
  28. package/rules/security/S017_use_parameterized_queries/symbol-based-analyzer.js +1146 -1
  29. package/rules/security/S019_smtp_injection_protection/analyzer.js +120 -0
  30. package/rules/security/S019_smtp_injection_protection/config.json +35 -0
  31. package/rules/security/S019_smtp_injection_protection/symbol-based-analyzer.js +687 -0
  32. package/rules/security/S020_no_eval_dynamic_code/analyzer.js +55 -130
  33. package/rules/security/S020_no_eval_dynamic_code/symbol-based-analyzer.js +4 -19
  34. package/rules/security/S022_escape_output_context/README.md +254 -0
  35. package/rules/security/S022_escape_output_context/analyzer.js +510 -0
  36. package/rules/security/S022_escape_output_context/config.json +229 -0
  37. package/rules/security/S023_no_json_injection/analyzer.js +15 -0
  38. package/rules/security/S023_no_json_injection/ast-analyzer.js +18 -3
  39. package/rules/security/S023_no_json_injection/config.json +133 -0
  40. package/rules/security/S024_xpath_xxe_protection/regex-based-analyzer.js +41 -0
  41. package/rules/security/S027_no_hardcoded_secrets/analyzer.js +67 -8
  42. package/rules/security/S027_no_hardcoded_secrets/categorized-analyzer.js +29 -6
  43. package/rules/security/S029_csrf_protection/config.json +127 -0
  44. package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +160 -28
  45. package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +81 -19
  46. package/rules/security/S031_secure_session_cookies/analyzer.js +20 -2
  47. package/rules/security/S031_secure_session_cookies/regex-based-analyzer.js +100 -0
  48. package/rules/security/S031_secure_session_cookies/symbol-based-analyzer.js +8 -1
  49. package/rules/security/S032_httponly_session_cookies/analyzer.js +2 -2
  50. package/rules/security/S032_httponly_session_cookies/regex-based-analyzer.js +115 -0
  51. package/rules/security/S032_httponly_session_cookies/symbol-based-analyzer.js +39 -10
  52. package/rules/security/S036_lfi_rfi_protection/analyzer.js +224 -0
  53. package/rules/security/S036_lfi_rfi_protection/config.json +20 -0
  54. package/rules/security/S040_session_fixation_protection/analyzer.js +153 -0
  55. package/rules/security/S040_session_fixation_protection/config.json +20 -0
  56. package/rules/security/S042_require_re_authentication_for_long_lived/README.md +83 -0
  57. package/rules/security/S042_require_re_authentication_for_long_lived/analyzer.js +153 -0
  58. package/rules/security/S042_require_re_authentication_for_long_lived/config.json +41 -0
  59. package/rules/security/S042_require_re_authentication_for_long_lived/symbol-based-analyzer.js +1139 -0
  60. package/rules/security/S043_password_changes_invalidate_all_sessions/README.md +107 -0
  61. package/rules/security/S043_password_changes_invalidate_all_sessions/analyzer.js +153 -0
  62. package/rules/security/S043_password_changes_invalidate_all_sessions/config.json +41 -0
  63. package/rules/security/S043_password_changes_invalidate_all_sessions/symbol-based-analyzer.js +541 -0
  64. package/docs/COMMAND-EXAMPLES.md +0 -390
  65. package/docs/FILE_LIMITS_COMPLETION_REPORT.md +0 -151
  66. package/docs/FOLDER_STRUCTURE.md +0 -59
  67. package/docs/SIMPLIFIED_USAGE_GUIDE.md +0 -208
  68. package/rules/security/S017_use_parameterized_queries/regex-based-analyzer.js +0 -541
  69. 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 (!hasErrorHandling && !this.isTestFile(filePath)) {
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 (exceptionVar && !this.isExceptionUsed(block, exceptionVar) && !this.hasExplicitIgnoreComment(catchClause)) {
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
- const hasInappropriateLevel = loggingInfo.logLevels.some(level =>
371
- this.config.inappropriateLevels.includes(level)
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.${loggingInfo.logLevels.join('/')} instead of console.error or console.warn`,
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 (exceptionVar && !loggingInfo.usesExceptionVar) {
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 pattern = new RegExp(`\\b${operation}\\s*\\(`, 'i');
782
- if (pattern.test(methodBody)) {
783
- violations.push({
784
- ruleId: this.ruleId,
785
- severity: 'warning',
786
- message: `Controller class '${className}' should not directly access database with '${operation}'. Use Service layer instead.`,
787
- file: filePath,
788
- line: method.getStartLineNumber(),
789
- column: 1
790
- });
791
- break;
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
- /pass/i,
68
+ /\bpass\b/i,
69
69
  /token/i,
70
- /auth/i,
71
- /credential/i,
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)