Skip to main content
interlace
Plugin: browser-securityRules

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 xLinking.openURLnavigation.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 URL

Mitigation: 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.

Why: We only detect Linking.openURL() and navigation.navigate(). Custom libraries not analyzed.

// ❌ NOT DETECTED - Custom library
import { openExternalURL } from './customLinking';
openExternalURL(userProvidedUrl); // Not detected

Mitigation: Apply validation pattern to all URL opening mechanisms.

📚 References

Use this rule

npm install -D oxlint-plugin-browser-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-browser-security"],  "rules": {    "browser-security/no-unvalidated-deeplinks": "error"  }}
vite.config.ts
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.