From 0f215a640c81f9262ab6528e7f596e9cdd676850 Mon Sep 17 00:00:00 2001 From: Daniel C Date: Fri, 13 Feb 2026 17:43:50 -0800 Subject: [PATCH 1/2] Add url-params-must-encode lint rule --- README.md | 18 ++++++++- src/rules/index.ts | 2 + src/rules/url-params-must-encode.ts | 57 ++++++++++++++++++++++++++++ tests/config-modes.test.ts | 2 +- tests/url-params-must-encode.test.ts | 54 ++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 src/rules/url-params-must-encode.ts create mode 100644 tests/url-params-must-encode.test.ts diff --git a/README.md b/README.md index e23d047..c181bdf 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ const allResults = lintJsxCode(code, { const ruleNames = getAllRuleNames(); // ['no-relative-paths', 'expo-image-import', ...] ``` -## Available Rules (33 total) +## Available Rules (34 total) ### Expo Router Rules @@ -158,6 +158,12 @@ const ruleNames = getAllRuleNames(); // ['no-relative-paths', 'expo-image-import | `no-response-json-lowercase` | warning | Use Response.json() instead of new Response(JSON.stringify()) | | `sql-no-nested-calls` | error | Don't nest sql template tags | +### Error Handling Rules + +| Rule | Severity | Description | +| ------------------------ | -------- | -------------------------------------------------------------- | +| `url-params-must-encode` | warning | URL query param values must be wrapped in encodeURIComponent() | + ### Code Style Rules | Rule | Severity | Description | @@ -420,6 +426,16 @@ if (typeof data === 'string') { const user: User = response.data; ``` +### `url-params-must-encode` + +```typescript +// Bad - unencoded query param +const url = `https://api.example.com?q=${query}`; + +// Good - encoded query param +const url = `https://api.example.com?q=${encodeURIComponent(query)}`; +``` + --- ## Adding a New Rule diff --git a/src/rules/index.ts b/src/rules/index.ts index 690e186..7cd5745 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -32,6 +32,7 @@ import { transitionSharedTagMismatch } from './transition-shared-tag-mismatch'; import { transitionPreferBlankStack } from './transition-prefer-blank-stack'; import { preferGuardClauses } from './prefer-guard-clauses'; import { noTypeAssertion } from './no-type-assertion'; +import { urlParamsMustEncode } from './url-params-must-encode'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -67,4 +68,5 @@ export const rules: Record = { 'transition-prefer-blank-stack': transitionPreferBlankStack, 'prefer-guard-clauses': preferGuardClauses, 'no-type-assertion': noTypeAssertion, + 'url-params-must-encode': urlParamsMustEncode, }; diff --git a/src/rules/url-params-must-encode.ts b/src/rules/url-params-must-encode.ts new file mode 100644 index 0000000..12ec3b4 --- /dev/null +++ b/src/rules/url-params-must-encode.ts @@ -0,0 +1,57 @@ +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'url-params-must-encode'; + +export function urlParamsMustEncode(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + TemplateLiteral(path) { + const { quasis, expressions } = path.node; + + for (let i = 0; i < expressions.length; i++) { + const expr = expressions[i]; + const precedingQuasi = quasis[i]; + const rawBefore = precedingQuasi.value.raw; + + // Check if the preceding text ends with a URL query param pattern: ?key= or &key= + if (!/[?&][a-zA-Z_][a-zA-Z0-9_]*=$/.test(rawBefore)) continue; + + // Check if the expression is wrapped in encodeURIComponent() + if ( + t.isCallExpression(expr) && + t.isIdentifier(expr.callee, { name: 'encodeURIComponent' }) + ) { + continue; + } + + // Also allow String() or toString() wrapping encodeURIComponent inside + if (t.isCallExpression(expr)) { + const arg = expr.arguments[0]; + if ( + arg && + t.isCallExpression(arg) && + t.isIdentifier(arg.callee, { name: 'encodeURIComponent' }) + ) { + continue; + } + } + + const { loc } = expr; + results.push({ + rule: RULE_NAME, + message: + 'URL query parameter value should be wrapped in encodeURIComponent() to prevent malformed URLs.', + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'warning', + }); + } + }, + }); + + return results; +} diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index 1f3d1f0..51cdb4e 100644 --- a/tests/config-modes.test.ts +++ b/tests/config-modes.test.ts @@ -100,7 +100,7 @@ describe('config modes', () => { expect(ruleNames).toContain('no-relative-paths'); expect(ruleNames).toContain('expo-image-import'); expect(ruleNames).toContain('no-stylesheet-create'); - expect(ruleNames.length).toBe(33); + expect(ruleNames.length).toBe(34); }); }); }); diff --git a/tests/url-params-must-encode.test.ts b/tests/url-params-must-encode.test.ts new file mode 100644 index 0000000..3802087 --- /dev/null +++ b/tests/url-params-must-encode.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const RULE = 'url-params-must-encode'; + +function lint(code: string) { + return lintJsxCode(code, { rules: [RULE] }); +} + +describe(RULE, () => { + it('flags unencoded value after ?key=', () => { + const code = 'const url = `https://api.example.com/search?q=${query}`;'; + const results = lint(code); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe(RULE); + expect(results[0].message).toContain('encodeURIComponent'); + }); + + it('flags unencoded value after &key=', () => { + const code = 'const url = `https://api.example.com/search?q=test&page=${page}`;'; + const results = lint(code); + expect(results).toHaveLength(1); + }); + + it('flags multiple unencoded params', () => { + const code = 'const url = `https://api.example.com?q=${query}&page=${page}`;'; + const results = lint(code); + expect(results).toHaveLength(2); + }); + + it('allows encodeURIComponent wrapped values', () => { + const code = 'const url = `https://api.example.com?q=${encodeURIComponent(query)}`;'; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows template literals without URL params', () => { + const code = 'const msg = `Hello ${name}, welcome!`;'; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('allows path segments (no query params)', () => { + const code = 'const url = `https://api.example.com/users/${userId}`;'; + const results = lint(code); + expect(results).toHaveLength(0); + }); + + it('does not flag non-template string concatenation', () => { + const code = 'const url = "https://api.example.com?q=" + query;'; + const results = lint(code); + expect(results).toHaveLength(0); + }); +}); From 8b9c3a723cb058a8b31da0e23ce2d58d77859d05 Mon Sep 17 00:00:00 2001 From: Daniel C Date: Fri, 13 Feb 2026 17:57:55 -0800 Subject: [PATCH 2/2] Fix comment and add test for nested wrapping path --- src/rules/url-params-must-encode.ts | 2 +- tests/url-params-must-encode.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/rules/url-params-must-encode.ts b/src/rules/url-params-must-encode.ts index 12ec3b4..5c5d426 100644 --- a/src/rules/url-params-must-encode.ts +++ b/src/rules/url-params-must-encode.ts @@ -28,7 +28,7 @@ export function urlParamsMustEncode(ast: File, _code: string): LintResult[] { continue; } - // Also allow String() or toString() wrapping encodeURIComponent inside + // Also allow function calls wrapping encodeURIComponent, e.g. String(encodeURIComponent(...)) if (t.isCallExpression(expr)) { const arg = expr.arguments[0]; if ( diff --git a/tests/url-params-must-encode.test.ts b/tests/url-params-must-encode.test.ts index 3802087..7399486 100644 --- a/tests/url-params-must-encode.test.ts +++ b/tests/url-params-must-encode.test.ts @@ -46,6 +46,12 @@ describe(RULE, () => { expect(results).toHaveLength(0); }); + it('allows String(encodeURIComponent(...)) wrapped values', () => { + const code = 'const url = `https://api.example.com?q=${String(encodeURIComponent(query))}`;'; + const results = lint(code); + expect(results).toHaveLength(0); + }); + it('does not flag non-template string concatenation', () => { const code = 'const url = "https://api.example.com?q=" + query;'; const results = lint(code);