Secure Message Passing in Chrome Extensions: Validate, Sanitize, and Authenticate

26 min read

Secure Message Passing in Chrome Extensions: Validate, Sanitize, and Authenticate

Message passing is the backbone of Chrome extension architecture, enabling communication between content scripts, background service workers, popups, side panels, and native applications. However, this flexibility comes with significant security implications. Without proper validation and authentication, extensions become vulnerable to message injection attacks, cross-site scripting through message channels, privilege escalation, and data exfiltration. This comprehensive guide covers the essential security patterns for message passing in Chrome extensions, from validating message senders to implementing type-safe protocols that protect millions of users from potential attacks.

Understanding the Chrome Extension Messaging Threat Landscape

Chrome extensions operate with elevated privileges within the browser, granting them access to sensitive APIs, user data, and browser functionality that standard web pages cannot reach. This makes secure message passing critical—an attacker who can inject malicious messages into your extension’s communication channels could potentially access cookies, hijack sessions, steal sensitive information, or perform actions on behalf of the user.

The threat landscape includes several attack vectors that developers must understand and defend against. Message injection occurs when an attacker sends crafted messages that appear to originate from trusted sources. Message spoofing involves messages that claim to be from legitimate extension contexts but are actually generated by malicious web pages or extensions. Replay attacks capture and re-transmit valid messages to execute actions repeatedly. Cross-origin messaging vulnerabilities arise when extensions communicate with external applications without proper validation.

Chrome Runtime SendMessage Security Fundamentals

The chrome.runtime.sendMessage API is the primary mechanism for one-way communication from content scripts to the background service worker and between other extension contexts. While convenient, it introduces security risks if implemented without proper validation.

When using chrome.runtime.sendMessage, always implement a sender validation strategy. The message listener receives a sender object containing crucial identification information:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // Validate sender origin and ID
  if (!sender.id || sender.id !== chrome.runtime.id) {
    console.error('Message from untrusted source rejected');
    return false;
  }

  // For content scripts, verify the tab URL
  if (sender.url && !sender.url.startsWith('https://trusted-domain.com')) {
    console.error('Message from untrusted URL rejected');
    return false;
  }

  // Process validated message
  handleMessage(message);
  return true;
});

The sender object provides the id property (the extension ID), url (the URL of the page that sent the message), tab (the tab that sent the message), and frameId (the frame that sent the message). Always validate these properties before processing any message, especially messages from content scripts that originate from web pages.

For enhanced security, implement message filtering at the entry points of your extension:

interface ValidatedMessage<T> {
  type: string;
  payload: T;
  timestamp: number;
  nonce: string;
}

function validateIncomingMessage(message: unknown): ValidatedMessage<unknown> | null {
  if (!message || typeof message !== 'object') {
    return null;
  }

  const msg = message as Record<string, unknown>;

  // Verify required fields exist
  if (typeof msg.type !== 'string' || typeof msg.payload !== 'object') {
    return null;
  }

  // Verify timestamp is recent (prevent replay)
  const maxAge = 5000; // 5 seconds
  if (typeof msg.timestamp !== 'number' || Date.now() - msg.timestamp > maxAge) {
    return null;
  }

  return msg as ValidatedMessage<unknown>;
}

Sender Validation Strategies

Sender validation is the foundation of secure message passing. Chrome provides multiple mechanisms to verify message origins, and using them comprehensively significantly reduces the attack surface.

Extension Context Verification

Every message includes sender information that should be verified:

interface SenderInfo {
  id: string;          // Extension ID
  url?: string;        // Page URL for content scripts
  tab?: Tab;           // Tab information
  frameId?: number;    // Frame ID
}

function validateSender(sender: SenderInfo): boolean {
  // Always verify the extension ID matches
  if (sender.id !== chrome.runtime.id) {
    return false;
  }

  // For content script messages, validate the URL
  if (sender.url) {
    const url = new URL(sender.url);

    // Block messages from about:, chrome:, and other internal pages
    if (!url.protocol.startsWith('http')) {
      return false;
    }

    // Implement allowlist for trusted domains
    const allowedDomains = ['trusted-domain.com', 'app.trusted-domain.com'];
    const isTrustedDomain = allowedDomains.some(domain =>
      url.hostname === domain || url.hostname.endsWith(`.${domain}`)
    );

    if (!isTrustedDomain) {
      return false;
    }
  }

  return true;
}

Tab-Based Validation

When receiving messages from content scripts, validate the associated tab:

async function validateTabPermission(tabId: number, requiredPermissions: string[]): Promise<boolean> {
  try {
    const tab = await chrome.tabs.get(tabId);

    if (!tab.url) {
      return false;
    }

    const url = new URL(tab.url);

    // Check protocol
    if (!url.protocol.startsWith('http')) {
      return false;
    }

    // Verify permissions match the tab URL
    const permissions = await chrome.permissions.contains({
      origins: [url.origin + '/*']
    });

    return permissions;
  } catch (error) {
    console.error('Tab validation failed:', error);
    return false;
  }
}

Message Schema Validation with Zod and Joi

Implementing schema validation ensures that messages conform to expected structures, preventing attacks that exploit malformed or unexpected message formats. Zod and Joi are popular validation libraries that work well in extension contexts.

Zod Schema Validation

Zod provides excellent TypeScript integration:

import { z } from 'zod';

// Define message schemas
const MessagePayloadSchema = z.object({
  action: z.enum(['fetch', 'update', 'delete', 'execute']),
  data: z.record(z.unknown()).optional(),
  requestId: z.string().uuid(),
  timestamp: z.number().int().positive()
});

const ResponseSchema = z.object({
  success: z.boolean(),
  data: z.unknown().optional(),
  error: z.string().optional(),
  requestId: z.string().uuid()
});

// Type inference
type MessagePayload = z.infer<typeof MessagePayloadSchema>;
type Response = z.infer<typeof ResponseSchema>;

function validateMessageSchema<T>(schema: z.ZodSchema<T>, data: unknown): T | null {
  try {
    return schema.parse(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Validation errors:', error.errors);
    }
    return null;
  }
}

// Usage in message handler
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  const validated = validateMessageSchema(MessagePayloadSchema, message);

  if (!validated) {
    sendResponse({ success: false, error: 'Invalid message schema' });
    return true;
  }

  if (!validateSender(sender)) {
    sendResponse({ success: false, error: 'Unauthorized sender' });
    return true;
  }

  // Process validated message
  handleValidatedMessage(validated);
  return true;
});

Joi Schema Validation

Joi provides an alternative with rich validation capabilities:

import Joi from 'joi';

const messageSchema = Joi.object({
  type: Joi.string()
    .valid('fetch', 'update', 'delete', 'execute')
    .required(),
  payload: Joi.object({
    id: Joi.string().uuid().required(),
    data: Joi.object().unknown()
  }).required(),
  timestamp: Joi.number()
    .integer()
    .min(Date.now() - 60000) // Within last minute
    .required(),
  nonce: Joi.string()
    .alphanum()
    .length(32)
    .required()
});

function validateJoiMessage(data: unknown): Joi.ValidationResult {
  return messageSchema.validate(data, {
    abortEarly: false,
    allowUnknown: false
  });
}

Port-Based Long-Lived Connections Security

The chrome.runtime.connect API creates persistent connections between extension contexts. These connections require their own security considerations.

Secure Port Implementation

class SecurePort {
  private port: chrome.runtime.Port;
  private messageHandlers: Map<string, (payload: unknown) => Promise<unknown>>;
  private validatedSender: SenderInfo | null = null;

  constructor(port: chrome.runtime.Port) {
    this.port = port;
    this.messageHandlers = new Map();
    this.setupListeners();
  }

  private setupListeners(): void {
    this.port.onMessage.addListener((message, sender) => {
      // Validate sender on each message for long-lived connections
      if (!this.validatedSender) {
        if (!validateSender(sender)) {
          this.port.disconnect();
          return;
        }
        this.validatedSender = sender;
      }

      // Validate message schema
      const validated = validateMessageSchema(MessagePayloadSchema, message);
      if (!validated) {
        this.sendError('Invalid message schema');
        return;
      }

      // Handle message
      this.handleMessage(validated);
    });

    this.port.onDisconnect.addListener(() => {
      console.log('Port disconnected');
    });
  }

  private async handleMessage(message: MessagePayload): Promise<void> {
    const handler = this.messageHandlers.get(message.action);
    if (!handler) {
      this.sendError(`Unknown action: ${message.action}`);
      return;
    }

    try {
      const result = await handler(message.data);
      this.port.postMessage({ success: true, data: result });
    } catch (error) {
      this.port.postMessage({
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      });
    }
  }

  private sendError(error: string): void {
    this.port.postMessage({ success: false, error });
  }

  registerHandler(action: string, handler: (payload: unknown) => Promise<unknown>): void {
    this.messageHandlers.set(action, handler);
  }
}

// Usage
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== 'secure-channel') {
    port.disconnect();
    return;
  }

  const securePort = new SecurePort(port);
  securePort.registerHandler('fetch', handleFetch);
  securePort.registerHandler('update', handleUpdate);
});

Native Messaging Security

Native messaging allows extensions to communicate with native applications installed on the user’s computer. This powerful feature requires stringent security measures.

Securing Native Message Ports

// Only allow specific native applications
const ALLOWED_NATIVE_APP = 'com.example.secure-app';

interface NativeMessage {
  command: string;
  params: Record<string, unknown>;
  requestId: string;
}

async function sendNativeMessage(message: NativeMessage): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const port = chrome.runtime.connectNative(ALLOWED_NATIVE_APP);

    const timeout = setTimeout(() => {
      port.disconnect();
      reject(new Error('Native message timeout'));
    }, 10000);

    port.onMessage.addListener((response) => {
      clearTimeout(timeout);
      resolve(response);
    });

    port.onDisconnect.addListener(() => {
      clearTimeout(timeout);
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
      } else {
        reject(new Error('Port disconnected'));
      }
    });

    port.postMessage(message);
  });
}

Native Messaging Validation

const nativeMessageSchema = z.object({
  command: z.enum(['getData', 'setData', 'execute']).required(),
  params: z.record(z.unknown()).optional(),
  requestId: z.string().uuid().required(),
  timestamp: z.number().int().positive().required()
});

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // Validate sender - only accept from our extension
  if (sender.id !== chrome.runtime.id) {
    sendResponse({ success: false, error: 'Unauthorized' });
    return true;
  }

  // Validate schema
  const validated = validateMessageSchema(nativeMessageSchema, message);
  if (!validated) {
    sendResponse({ success: false, error: 'Invalid message format' });
    return true;
  }

  // Process message
  handleNativeMessage(validated).then(sendResponse);
  return true;
});

Cross-Origin Messaging and Externally Connectable Restrictions

The externally_connectable manifest key controls which external websites can connect to your extension. Proper configuration is critical for security.

Manifest Configuration

{
  "manifest_version": 3,
  "name": "Secure Extension",
  "version": "1.0.0",
  "externally_connectable": {
    "matches": [
      "https://trusted-domain.com/*",
      "https://app.trusted-domain.com/*"
    ],
    "ids": ["*"]
  }
}

Implementing Cross-Origin Message Validation

// For messages from external web pages
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // Validate sender URL matches externally_connectable matches
  if (!sender.url) {
    sendResponse({ success: false, error: 'No sender URL' });
    return true;
  }

  const url = new URL(sender.url);
  const allowedPatterns = [
    'https://trusted-domain.com/*',
    'https://app.trusted-domain.com/*'
  ];

  const isAllowed = allowedPatterns.some(pattern => {
    // Simple pattern matching
    const regex = new RegExp(pattern.replace('*', '.*'));
    return regex.test(url.href);
  });

  if (!isAllowed) {
    sendResponse({ success: false, error: 'Origin not allowed' });
    return true;
  }

  // Additional validation
  const validated = validateMessageSchema(ExternalMessageSchema, message);
  if (!validated) {
    sendResponse({ success: false, error: 'Invalid message' });
    return true;
  }

  // Process message
  handleExternalMessage(validated).then(sendResponse);
  return true;
});

Message Replay Prevention

Message replay attacks involve capturing valid messages and re-transmitting them to execute unauthorized actions. Implementing timestamp validation and nonce tracking prevents these attacks.

Implementing Replay Protection

class ReplayProtection {
  private seenNonces: Set<string> = new Set();
  private maxAge: number = 60000; // 1 minute
  private cleanupInterval: NodeJS.Timeout;

  constructor() {
    // Clean up old nonces every minute
    this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
  }

  private cleanup(): void {
    const now = Date.now();
    for (const nonce of this.seenNonces) {
      // In production, you'd store timestamps with nonces
      // This is a simplified cleanup
    }
  }

  isValid(nonce: string, timestamp: number): boolean {
    // Check timestamp is recent
    const now = Date.now();
    if (now - timestamp > this.maxAge) {
      return false;
    }

    // Check nonce hasn't been seen
    if (this.seenNonces.has(nonce)) {
      return false;
    }

    // Mark nonce as seen
    this.seenNonces.add(nonce);
    return true;
  }

  destroy(): void {
    clearInterval(this.cleanupInterval);
  }
}

// Usage in message handler
const replayProtection = new ReplayProtection();

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (typeof message.nonce !== 'string' || typeof message.timestamp !== 'number') {
    sendResponse({ success: false, error: 'Missing replay protection fields' });
    return true;
  }

  if (!replayProtection.isValid(message.nonce, message.timestamp)) {
    sendResponse({ success: false, error: 'Message replay detected' });
    return true;
  }

  // Process message
  handleMessage(message);
  return true;
});

Type-Safe Messaging with TypeScript

TypeScript provides compile-time type safety for message passing, reducing runtime errors and improving security through static analysis.

Defining Type-Safe Message Protocols

// message-types.ts
import { z } from 'zod';

// Define action types
export const ActionType = {
  FETCH_DATA: 'fetchData',
  UPDATE_SETTINGS: 'updateSettings',
  EXECUTE_ACTION: 'executeAction'
} as const;

export type ActionType = typeof ActionType[keyof typeof ActionType];

// Define payload schemas
export const PayloadSchemas = {
  [ActionType.FETCH_DATA]: z.object({
    resourceId: z.string().uuid(),
    options: z.object({
      includeMetadata: z.boolean().optional()
    }).optional()
  }),

  [ActionType.UPDATE_SETTINGS]: z.object({
    settings: z.object({
      theme: z.enum(['light', 'dark', 'auto']).optional(),
      notifications: z.boolean().optional()
    })
  }),

  [ActionType.EXECUTE_ACTION]: z.object({
    actionId: z.string(),
    params: z.record(z.unknown()).optional()
  })
} as const;

// Union type for all message types
export type MessagePayload =
  | { type: ActionType.FETCH_DATA; payload: z.infer<typeof PayloadSchemas[ActionType.FETCH_DATA]> }
  | { type: ActionType.UPDATE_SETTINGS; payload: z.infer<typeof PayloadSchemas[ActionType.UPDATE_SETTINGS]> }
  | { type: ActionType.EXECUTE_ACTION; payload: z.infer<typeof PayloadSchemas[ActionType.EXECUTE_ACTION]> };

// Type guards
export function isMessagePayload(data: unknown): data is MessagePayload {
  if (!data || typeof data !== 'object') return false;

  const obj = data as Record<string, unknown>;

  if (typeof obj.type !== 'string') return false;
  if (!Object.values(ActionType).includes(obj.type as ActionType)) return false;

  const schema = PayloadSchemas[obj.type as ActionType];
  return schema.safeParse(obj.payload).success;
}

// Message handler type
export type MessageHandler<T extends MessagePayload> = (
  payload: T['payload'],
  sender: chrome.runtime.MessageSender
) => Promise<unknown>;

Implementing Type-Safe Message Router

// message-router.ts
import { ActionType, MessagePayload, PayloadSchemas, isMessagePayload } from './message-types';

type HandlerMap = {
  [K in MessagePayload as K['type']]?: (payload: K['payload']) => Promise<unknown>;
};

export class TypeSafeMessageRouter {
  private handlers: HandlerMap = {};

  register<T extends MessagePayload['type']>(
    type: T,
    handler: (payload: z.infer<typeof PayloadSchemas[T]>) => Promise<unknown>
  ): void {
    this.handlers[type] = handler;
  }

  async handleMessage(
    message: unknown,
    sender: chrome.runtime.MessageSender
  ): Promise<{ success: boolean; data?: unknown; error?: string }> {
    // Validate message structure
    if (!isMessagePayload(message)) {
      return { success: false, error: 'Invalid message type' };
    }

    // Validate sender
    if (!validateSender(sender)) {
      return { success: false, error: 'Unauthorized sender' };
    }

    // Find handler
    const handler = this.handlers[message.type];
    if (!handler) {
      return { success: false, error: `No handler for type: ${message.type}` };
    }

    try {
      const result = await handler(message.payload);
      return { success: true, data: result };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Handler error'
      };
    }
  }
}

// Usage
const router = new TypeSafeMessageRouter();

router.register(ActionType.FETCH_DATA, async (payload) => {
  const { resourceId } = payload;
  return await fetchResource(resourceId);
});

router.register(ActionType.UPDATE_SETTINGS, async (payload) => {
  await updateSettings(payload.settings);
  return { success: true };
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  router.handleMessage(message, sender).then(sendResponse);
  return true;
});

Best Practices Summary

Implementing secure message passing in Chrome extensions requires a defense-in-depth approach combining multiple security layers. Always validate message senders before processing any message, implementing allowlists for trusted origins and domains. Use schema validation libraries like Zod or Joi to ensure messages conform to expected structures, preventing attacks that exploit malformed data. Implement replay protection through timestamp validation and nonce tracking to prevent message replay attacks. Configure externally_connectable in your manifest to restrict which external websites can communicate with your extension.

For native messaging, always validate the native application identity and implement strict input validation on both sides of the communication channel. Use TypeScript to establish type-safe message protocols that provide compile-time safety and enable static analysis of your message handling code.

Finally, always treat data from web pages as potentially malicious. Content scripts run in the context of web pages, meaning any data they send to your extension could be crafted by an attacker. Implement thorough validation at every boundary between untrusted and trusted contexts.


Part of the Chrome Extension Guide by theluckystrike. More at zovo.one.

No previous article
No next article