Secure Message Passing in Chrome Extensions: Validate, Sanitize, and Authenticate
26 min readSecure 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.
Related Articles
- Security Best Practices
- Security Hardening
- XSS Prevention and Input Sanitization
- Chrome Extension Security Checklist
- Advanced Messaging Patterns
Part of the Chrome Extension Guide by theluckystrike. More at zovo.one.