no-unvalidated-deeplinks
This rule detects when deep link URLs are opened without validation in React Native or mobile web apps
Requires validation of deep link URLs before navigation
Severity: 🟠 HIGH
CWE: CWE-939: Improper Authorization in Handler for Custom URL Scheme
OWASP Mobile: M4: Insufficient Input/Output Validation
Rule Details
This rule detects when deep link URLs are opened without validation in React Native or mobile web apps. Unvalidated deep links enable phishing attacks, unauthorized actions, and open redirect vulnerabilities. The rule flags Linking.openURL(x) and navigation.navigate(x) when x is a URL somebody outside the app chose — proven from the code, not guessed from the argument being a variable:
Source of x | Linking.openURL | navigation.navigate |
|---|---|---|
the inbound deep link: event.url on a Linking.addEventListener('url', …) handler, or url destructured off that event (({ url }) => …, const { url: link } = event) | ✅ reported | ✅ reported |
Linking.getInitialURL() — awaited, or the .then((url) => …) parameter | ✅ reported | ✅ reported |
React Navigation route params (route.params.x, state.routes[i].params.x) | ✅ reported | — (names an in-app screen) |
the web URL (location.search, new URLSearchParams(location.search).get('next'), …) | ✅ reported | ✅ reported |
| a plain function parameter, a prop, a literal, a constant | — | — |
Linking is resolved through its import, so import { Linking as RNLinking } from 'react-native' and import * as Linking from 'expo-linking' are both recognised; the bare global Linking is accepted when nothing in the file binds the name. A local helper the rule can read to be the identity function passes the deep link through; any other call is treated as a validator.
A call is not reported when a guard in front of it validates the destination — an enclosing if or an early if (!…) return whose test is an exact allowlist (ALLOWED.has(url), ALLOWED.includes(url)), a validator call over the URL whose body is not in this file, an anchored host regexp, a relative-path check, or an origin equality. A truthiness check (if (url)) validates nothing.
A plain parameter (function handleDeepLink(url) { Linking.openURL(url) }) is not reported: nothing in the file proves an attacker supplies it, and Linking.openURL(SUPPORT_URL) with a constant was the false positive that motivated the narrowing.
Why This Matters
Deep links allow external apps/websites to trigger actions in your app:
- Phishing: Attacker crafts malicious deep link to trick users
- Open redirects: Users redirected to malicious sites
- Unauthorized actions: Deep links bypass normal auth flows
- CSRF: Cross-site request forgery via deep links
❌ Incorrect
import { Linking } from 'react-native';
// Deep link handler without validation
Linking.addEventListener('url', (event) => {
const { url } = event;
Linking.openURL(url); // ❌ Directly opening the inbound deep link
});
// The cold-start link
Linking.getInitialURL().then((url) => {
if (url) navigation.navigate(url); // ❌ A truthiness check is not validation
});
// A route param chosen by the link that opened the screen
function Screen({ route }) {
Linking.openURL(route.params.next); // ❌
}✅ Correct
import { Linking } from 'react-native';
const ALLOWED = new Set(['myapp://home', 'myapp://settings']);
Linking.addEventListener('url', ({ url }) => {
if (ALLOWED.has(url)) Linking.openURL(url); // exact allowlist
});
Linking.addEventListener('url', (event) => {
if (!isAllowedDeepLink(event.url)) return; // validator imported from elsewhere
navigation.navigate(event.url);
});
// A constant destination
Linking.openURL('https://help.example.com');
// A screen name is not a URL
navigation.navigate('Settings');
// Not proven to come from outside the app (no finding; validate it anyway)
function openHelp(url: string) {
Linking.openURL(url);
}⚙️ Configuration
This rule has no configuration options.
Known False Negatives
Arguments with no proven external source
Why: Only deep-link and web-URL sources are tracked. A URL passed in as a plain parameter or prop is not proven attacker-chosen, so it is not reported.
// ❌ NOT DETECTED - plain parameter
function handleDeepLink(url: string) {
Linking.openURL(url);
}Mitigation: Validate at the entry point that receives the link.
Literal URL Strings
Why: Literal strings are assumed safe (but still review manually).
// ❌ NOT DETECTED - Literal string
Linking.openURL('https://evil.com'); // Literal, but still dangerous if hardcoded malicious URLMitigation: Code review all openURL() calls. Prefer whitelisted literals only.
Validators defined in the same file
Why: A validator whose body is in this file is read rather than trusted; unless it is the identity, it is not taken as proof of validation by name alone, so a guard built on it is not recognised.
// Reported even though a local validate() runs first
function validate(url: string): boolean {
return url.startsWith('myapp:');
}
Linking.addEventListener('url', ({ url }) => {
if (validate(url)) Linking.openURL(url);
});Mitigation: Use an exact allowlist inline, or import the validator.
Custom Link Opening Libraries
Why: We only detect Linking.openURL() and navigation.navigate(). Custom libraries not analyzed.
// ❌ NOT DETECTED - Custom library
import { openExternalURL } from './customLinking';
openExternalURL(userProvidedUrl); // Not detectedMitigation: Apply validation pattern to all URL opening mechanisms.
🔗 Related Rules
require-url-validation- General URL validationno-insecure-redirects- Server-side redirect validation
📚 References
- CWE-939: Improper Authorization in Handler for Custom URL Scheme
- OWASP Mobile M4: Insufficient Input/Output Validation
- React Native Linking API
- iOS Universal Links
Use this rule
npm install -D oxlint-plugin-browser-security{ "jsPlugins": ["oxlint-plugin-browser-security"], "rules": { "browser-security/no-unvalidated-deeplinks": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-browser-security'], rules: { 'browser-security/no-unvalidated-deeplinks': 'error', }, },});oxlint-plugin-browser-security re-exports eslint-plugin-browser-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.