roach using TypeScript.
Architecture Decisions
- Segment-Level Decoding: Instead of decoding the entire query string at once, we process each key-value pair independently. This isolates malformed sequences and prevents cascading failures.
- Iterative Path Resolution: Bracket notation (
filter[author][name]) is resolved using an iterative stack rather than recursive regex matching. This eliminates Regular Expression Denial of Service (ReDoS) vulnerabilities and guarantees O(n) performance.
- JSON Auto-Detection: Values matching JSON syntax are automatically parsed and formatted. This removes the need for secondary formatting tools during debugging.
- Strict Execution Boundary: The parser runs entirely in the local runtime. No outbound requests, no analytics listeners, and no external dependencies.
Implementation
type QueryNode = string | string[] | Record<string, QueryNode>;
type QueryTree = Record<string, QueryNode>;
export class QueryStringAnalyzer {
private static readonly BRACKET_PATH_REGEX = /^([^\[\]]+)(?:\[(.+)\])?$/;
/**
* Decodes a single URI segment with graceful fallback for malformed percent sequences.
*/
private static decodeSegment(raw: string): string {
if (!raw) return raw;
try {
return decodeURIComponent(raw);
} catch {
// Fallback: decode valid hex pairs, preserve invalid sequences
return raw.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
try {
return decodeURIComponent(`%${hex}`);
} catch {
return match;
}
});
}
}
/**
* Resolves bracket notation paths into a nested object structure.
* Example: "user[profile][settings]" -> { user: { profile: { settings: value } } }
*/
private static resolvePath(target: QueryTree, path: string, value: QueryNode): void {
const segments = path.split('[').map(s => s.replace(']', ''));
let current: QueryNode = target;
for (let i = 0; i < segments.length; i++) {
const key = segments[i];
const isFinal = i === segments.length - 1;
if (isFinal) {
if (Array.isArray(current[key])) {
(current[key] as string[]).push(value as string);
} else if (current[key] !== undefined) {
current[key] = [current[key] as string, value as string];
} else {
current[key] = value;
}
return;
}
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, QueryNode>;
}
}
/**
* Attempts to parse a string as JSON. Returns formatted string on success, original on failure.
*/
private static tryFormatJson(raw: string): string {
if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
return raw;
}
/**
* Main entry point: parses a raw URL or query string into a structured tree.
*/
public static parse(input: string): QueryTree {
const queryPart = input.includes('?') ? input.split('?')[1] : input;
if (!queryPart?.trim()) return {};
const tree: QueryTree = {};
const pairs = queryPart.split('&');
for (const pair of pairs) {
const [rawKey, rawValue] = pair.split('=');
if (!rawKey) continue;
// Normalize form-encoded spaces vs literal plus signs
const cleanKey = this.decodeSegment(rawKey.replace(/\+/g, ' '));
const cleanValue = rawValue
? this.decodeSegment(rawValue.replace(/\+/g, ' '))
: '';
const formattedValue = this.tryFormatJson(cleanValue);
this.resolvePath(tree, cleanKey, formattedValue);
}
return tree;
}
}
Why This Works
decodeSegment isolates errors. If a URL contains status=active%, the fallback regex only decodes valid %XX sequences, leaving the trailing % intact. The rest of the payload remains accessible.
resolvePath uses string splitting instead of regex capture groups. This guarantees linear time complexity and prevents catastrophic backtracking when processing unusually long parameter names.
tryFormatJson removes the manual copy-paste step. Debugging OAuth callbacks or webhook payloads becomes a single operation: parse, inspect, validate.
- Zero external dependencies ensure the parser can be embedded in browser extensions, local dev servers, or CI/CD validation scripts without supply chain risk.
Pitfall Guide
1. The + Sign Ambiguity Trap
Explanation: Developers assume + always means space. RFC 3986 treats it as literal, while form-encoding treats it as space. Blindly replacing + with space corrupts cryptographic signatures, phone numbers, and base64 strings.
Fix: Context-aware normalization. Only replace + with space when parsing form-encoded payloads. Preserve + for raw URI components.
2. Unhandled URIError Cascades
Explanation: decodeURIComponent throws synchronously on malformed percent sequences. Wrapping the entire query string in a single try/catch obscures valid parameters and halts execution.
Fix: Decode at the segment level. Isolate failures so valid keys remain accessible while invalid sequences are preserved or flagged.
3. Regex-Driven ReDoS in Key Parsing
Explanation: Using nested capture groups to parse bracket notation (\[(.+?)\]) on untrusted input can trigger catastrophic backtracking, freezing the main thread.
Fix: Use iterative string splitting or bounded character classes. Avoid unbounded quantifiers inside nested groups.
4. Double-Encoding Blind Spots
Explanation: URLs passed through multiple redirect layers often double-encode (%2520 instead of %20). Standard decoders only run once, leaving escaped characters visible.
Fix: Implement iterative decoding with a maximum pass limit (e.g., 3 passes). Stop when the string length stabilizes to prevent infinite loops.
5. Third-Party Parser Supply Chain Risk
Explanation: NPM packages like querystring or qs are widely used but occasionally introduce prototype pollution vulnerabilities or inefficient regex patterns.
Fix: Audit dependencies, prefer native implementations for simple use cases, and sandbox third-party parsers in isolated worker threads when processing untrusted payloads.
6. Flattening Multi-Value Arrays
Explanation: Query strings like tags=js&tags=ts are often reduced to a single string, losing array semantics required for filtering or batch operations.
Fix: Detect duplicate keys during parsing. Convert the first occurrence to a string, then promote to an array on subsequent matches.
7. Missing Content Security Policy in Dev Tools
Explanation: Local debugging interfaces often lack CSP headers, allowing injected scripts to read window.location or intercept paste events.
Fix: Enforce default-src 'self' and script-src 'self' in local dev servers. Disable paste event listeners that log to external endpoints.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Quick ad-hoc debugging | Local-First Sandbox Parser | Zero setup, immediate tree visualization, no data leakage | Free (local runtime) |
| CI/CD pipeline validation | Native URLSearchParams + strict validation | Fast, standardized, integrates with test runners | Low (build time overhead) |
| Enterprise compliance audit | Isolated Web Worker Parser | Guarantees zero network egress, prevents memory leaks in main thread | Medium (worker management) |
| High-throughput webhook processing | Stream-based decoder with pass limits | Prevents blocking, handles double-encoding safely | Medium (compute overhead) |
Configuration Template
// queryAnalyzer.config.ts
import { QueryStringAnalyzer } from './QueryStringAnalyzer';
export interface DebugSessionConfig {
maxDecodePasses: number;
enableJsonFormatting: boolean;
stripPiiPatterns: RegExp[];
}
export const defaultConfig: DebugSessionConfig = {
maxDecodePasses: 3,
enableJsonFormatting: true,
stripPiiPatterns: [
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, // Emails
/\b\d{3}-\d{2}-\d{4}\b/g, // SSN
/\b\d{16}\b/g, // Credit card patterns
],
};
export function sanitizeAndParse(rawUrl: string, config: DebugSessionConfig = defaultConfig): Record<string, unknown> {
let sanitized = rawUrl;
config.stripPiiPatterns.forEach(pattern => {
sanitized = sanitized.replace(pattern, '[REDACTED]');
});
const parsed = QueryStringAnalyzer.parse(sanitized);
if (config.enableJsonFormatting) {
Object.keys(parsed).forEach(key => {
const val = parsed[key];
if (typeof val === 'string' && (val.startsWith('{') || val.startsWith('['))) {
try {
parsed[key] = JSON.parse(val);
} catch {
// Keep as string if invalid JSON
}
}
});
}
return parsed;
}
Quick Start Guide
- Initialize the module: Copy the
QueryStringAnalyzer class into your project's utility directory. Ensure TypeScript strict mode is enabled for type safety.
- Integrate with your debugger: Replace manual
decodeURIComponent calls with QueryStringAnalyzer.parse(window.location.search) in your local dev console or debugging UI.
- Validate edge cases: Run the provided configuration template against known problematic payloads (OAuth callbacks, webhook signatures, tracking links) to verify graceful fallback behavior.
- Enforce isolation: If deploying as a browser extension or local dev server, add
Content-Security-Policy: default-src 'self'; script-src 'self' to prevent external script injection.
- Automate in CI: Add a test suite that feeds malformed, double-encoded, and nested query strings into the parser. Assert that no
URIError escapes and that bracket notation resolves correctly.