Comprehensive security guide for browser extensions covering Content Security Policy, permissions model, secure messaging, sandboxing, storage security, and threat mitigation patterns.
Provides comprehensive security guidance for browser extension development covering CSP, permissions, messaging, and threat mitigation.
npx claudepluginhub arustydev/aiThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Comprehensive security guide for browser extensions covering Content Security Policy, permissions model, secure messaging, sandboxing, storage security, and threat mitigation patterns.
Browser extensions operate with elevated privileges. Security failures can expose users to data theft, credential compromise, and malicious code execution.
┌─────────────────────────────────────────────────────────────┐
│ Extension Context │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Popup │ │ Options │ │ Service Worker │ │
│ │ (sandbox) │ │ (sandbox) │ │ (privileged) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │ │
│ └────────────────┼────────────────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Message Channel │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Content Scripts │ │
│ │ (isolated world) │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals"
}
}
| Directive | Purpose | Recommended Value |
|---|---|---|
script-src | Script sources | 'self' only |
object-src | Plugin sources | 'self' or 'none' |
style-src | Stylesheet sources | 'self' |
img-src | Image sources | 'self' data: https: |
connect-src | XHR/fetch targets | Specific origins |
frame-src | iframe sources | 'self' or 'none' |
worker-src | Worker sources | 'self' |
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com; frame-src 'none'"
}
}
| Pattern | Risk | Alternative |
|---|---|---|
'unsafe-eval' | Code injection | Static code, no eval() |
'unsafe-inline' | XSS | External scripts only |
* wildcard | Unrestricted | Specific domains |
data: for scripts | Code injection | Bundle scripts |
blob: for scripts | Code injection | Static imports |
Never do this:
// DANGEROUS: eval() allows code injection
eval(userInput);
// DANGEROUS: new Function() same risk
const fn = new Function('return ' + userInput);
// DANGEROUS: innerHTML with user content
element.innerHTML = userContent;
Do this instead:
// Safe: Static code only
import { processData } from './processor';
// Safe: Text content for user data
element.textContent = userContent;
// Safe: Structured clone for data
const data = structuredClone(userInput);
| Type | Declaration | User Prompt | Best For |
|---|---|---|---|
| Required | permissions | Install time | Core functionality |
| Optional | optional_permissions | Runtime | Feature gates |
| Host | host_permissions | Install/runtime | Site access |
| Optional host | optional_host_permissions | Runtime | User-selected sites |
{
"permissions": [
"storage"
],
"optional_permissions": [
"tabs",
"bookmarks"
],
"host_permissions": [],
"optional_host_permissions": [
"https://*.example.com/*"
]
}
// Request when user triggers feature
async function enableAdvancedFeature(): Promise<boolean> {
const granted = await browser.permissions.request({
permissions: ['tabs'],
origins: ['https://api.example.com/*']
});
if (granted) {
await initializeAdvancedFeature();
}
return granted;
}
// Check before using
async function requiresPermission(): Promise<void> {
const hasPermission = await browser.permissions.contains({
permissions: ['tabs']
});
if (!hasPermission) {
throw new Error('Feature requires tabs permission');
}
}
// Type-safe permission checking
type RequiredPermission = 'storage' | 'alarms';
type OptionalPermission = 'tabs' | 'bookmarks';
async function checkPermissions(
required: RequiredPermission[]
): Promise<boolean> {
return browser.permissions.contains({ permissions: required });
}
// Never request permissions beyond what's declared
// manifest.json must include all permissions that can be requested
// Define strict message types
interface Messages {
GET_DATA: { key: string };
SET_DATA: { key: string; value: unknown };
FETCH_URL: { url: string; options?: RequestInit };
}
type MessageType = keyof Messages;
interface Message<T extends MessageType> {
type: T;
payload: Messages[T];
nonce: string;
}
// Background service worker
browser.runtime.onMessage.addListener(
(message: unknown, sender, sendResponse) => {
// Validate sender
if (!isValidSender(sender)) {
console.warn('Invalid sender:', sender);
return false;
}
// Validate message structure
if (!isValidMessage(message)) {
console.warn('Invalid message:', message);
return false;
}
// Handle by type
handleMessage(message, sender).then(sendResponse);
return true; // Async response
}
);
function isValidSender(sender: browser.Runtime.MessageSender): boolean {
// Only accept from our extension
if (sender.id !== browser.runtime.id) return false;
// Verify URL if from content script
if (sender.url) {
try {
const url = new URL(sender.url);
if (!ALLOWED_ORIGINS.includes(url.origin)) return false;
} catch {
return false;
}
}
return true;
}
function isValidMessage(msg: unknown): msg is Message<MessageType> {
if (!msg || typeof msg !== 'object') return false;
const m = msg as Record<string, unknown>;
if (typeof m.type !== 'string') return false;
if (!MESSAGE_TYPES.includes(m.type as MessageType)) return false;
if (typeof m.nonce !== 'string' || m.nonce.length !== 32) return false;
return true;
}
// content-script.ts
async function sendToBackground<T extends MessageType>(
type: T,
payload: Messages[T]
): Promise<unknown> {
const message: Message<T> = {
type,
payload,
nonce: crypto.randomUUID().replace(/-/g, '')
};
try {
return await browser.runtime.sendMessage(message);
} catch (error) {
// Handle disconnected extension
console.error('Message failed:', error);
throw error;
}
}
// Usage
const data = await sendToBackground('GET_DATA', { key: 'settings' });
{
"externally_connectable": {
"matches": [
"https://app.example.com/*",
"https://dashboard.example.com/*"
]
}
}
// Validate external messages strictly
browser.runtime.onMessageExternal.addListener(
(message, sender, sendResponse) => {
// Verify sender URL
if (!sender.url || !ALLOWED_EXTERNAL_ORIGINS.includes(new URL(sender.url).origin)) {
return false;
}
// External messages have limited actions
if (!EXTERNAL_ALLOWED_ACTIONS.includes(message.action)) {
return false;
}
handleExternalMessage(message, sender).then(sendResponse);
return true;
}
);
// For long-lived connections
const ports = new Map<string, browser.Runtime.Port>();
browser.runtime.onConnect.addListener((port) => {
// Validate port name format
if (!/^content-\d+$/.test(port.name)) {
port.disconnect();
return;
}
ports.set(port.name, port);
port.onMessage.addListener((message) => {
// Validate and handle
if (isValidPortMessage(message)) {
handlePortMessage(port, message);
}
});
port.onDisconnect.addListener(() => {
ports.delete(port.name);
});
});
Content scripts run in an isolated world but share DOM:
// Content scripts CANNOT access:
// - Page's JavaScript variables
// - Page's prototype modifications
// - Other extensions' content scripts
// Content scripts CAN access:
// - DOM (read/write)
// - Standard browser APIs
// - Extension messaging APIs
// - Subset of browser.* APIs
// Clone elements to avoid prototype pollution
function safeGetAttribute(element: Element, attr: string): string | null {
return Element.prototype.getAttribute.call(element, attr);
}
function safeSetAttribute(element: Element, attr: string, value: string): void {
Element.prototype.setAttribute.call(element, attr, value);
}
// Use MutationObserver with caution
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// Don't trust mutation data from page
if (!isOurElement(mutation.target)) continue;
handleMutation(mutation);
}
});
{
"sandbox": {
"pages": ["sandbox.html"]
}
}
// Main page communicates via postMessage
const sandbox = document.getElementById('sandbox') as HTMLIFrameElement;
// Send data to sandbox
sandbox.contentWindow?.postMessage({
type: 'PROCESS',
data: untrustedData
}, '*');
// Receive results
window.addEventListener('message', (event) => {
// Verify origin
if (event.source !== sandbox.contentWindow) return;
if (event.data.type === 'RESULT') {
handleResult(event.data.result);
}
});
{
"web_accessible_resources": [
{
"resources": ["injected.js"],
"matches": ["https://specific-site.com/*"],
"use_dynamic_url": true
}
]
}
Security considerations:
use_dynamic_url: true for fingerprinting protectionmatches to specific sites| Type | Encryption | Sync | Quota | Best For |
|---|---|---|---|---|
storage.local | None | No | 10MB | Large data, local-only |
storage.sync | Transit only | Yes | 100KB | Settings, cross-device |
storage.session | Memory only | No | 10MB | Temporary, sensitive |
// Encryption wrapper for storage
class SecureStorage {
private key: CryptoKey | null = null;
async init(): Promise<void> {
// Derive key from extension ID (unique per install)
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(browser.runtime.id),
'PBKDF2',
false,
['deriveKey']
);
this.key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode('extension-salt'),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async setSecure(key: string, value: unknown): Promise<void> {
if (!this.key) throw new Error('Not initialized');
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = encoder.encode(JSON.stringify(value));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.key,
data
);
await browser.storage.local.set({
[key]: {
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted))
}
});
}
async getSecure<T>(key: string): Promise<T | null> {
if (!this.key) throw new Error('Not initialized');
const result = await browser.storage.local.get(key);
if (!result[key]) return null;
const { iv, data } = result[key];
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(iv) },
this.key,
new Uint8Array(data)
);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(decrypted));
}
}
// Use session storage for sensitive data that shouldn't persist
// Data is cleared when browser closes
async function storeTemporaryCredentials(creds: Credentials): Promise<void> {
// Session storage is memory-only, never written to disk
await browser.storage.session.set({
credentials: creds,
timestamp: Date.now()
});
}
async function getTemporaryCredentials(): Promise<Credentials | null> {
const result = await browser.storage.session.get(['credentials', 'timestamp']);
if (!result.credentials) return null;
// Expire after 1 hour
if (Date.now() - result.timestamp > 3600000) {
await browser.storage.session.remove(['credentials', 'timestamp']);
return null;
}
return result.credentials;
}
const ALLOWED_PROTOCOLS = ['https:', 'http:'];
const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com'];
function validateUrl(input: string): URL | null {
try {
const url = new URL(input);
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
console.warn('Invalid protocol:', url.protocol);
return null;
}
if (!ALLOWED_HOSTS.includes(url.hostname)) {
console.warn('Invalid host:', url.hostname);
return null;
}
// Remove credentials
url.username = '';
url.password = '';
return url;
} catch {
console.warn('Invalid URL:', input);
return null;
}
}
import Ajv from 'ajv';
const ajv = new Ajv({ allErrors: true });
const settingsSchema = {
type: 'object',
properties: {
theme: { type: 'string', enum: ['light', 'dark'] },
fontSize: { type: 'number', minimum: 8, maximum: 32 },
notifications: { type: 'boolean' }
},
required: ['theme'],
additionalProperties: false
};
const validateSettings = ajv.compile(settingsSchema);
function parseSettings(input: unknown): Settings | null {
if (validateSettings(input)) {
return input as Settings;
}
console.warn('Invalid settings:', validateSettings.errors);
return null;
}
// For displaying user content in extension UI
function sanitizeHtml(input: string): string {
const div = document.createElement('div');
div.textContent = input; // Escapes all HTML
return div.innerHTML;
}
// For rich text (use DOMPurify)
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'br'];
const ALLOWED_ATTR = ['href'];
function sanitizeRichText(input: string): string {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false
});
}
interface FetchOptions {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: unknown;
timeout?: number;
}
async function secureFetch<T>(options: FetchOptions): Promise<T> {
const validatedUrl = validateUrl(options.url);
if (!validatedUrl) {
throw new Error('Invalid URL');
}
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
options.timeout ?? 30000
);
try {
const response = await fetch(validatedUrl.toString(), {
method: options.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
// Don't send cookies to third parties
'credentials': 'same-origin'
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Validate content type
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new Error('Invalid content type');
}
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
// Never send credentials to untrusted origins
async function fetchWithCors(url: string): Promise<Response> {
return fetch(url, {
mode: 'cors',
credentials: 'omit', // Never send cookies
referrerPolicy: 'no-referrer'
});
}
| Attack Vector | Mitigation |
|---|---|
| innerHTML | Use textContent or sanitize |
| eval() | Disallow via CSP |
| URL parameters | Validate and sanitize |
| postMessage | Verify origin |
| DOM clobbering | Use unique IDs |
| Attack Vector | Mitigation |
|---|---|
| eval() | Static code only |
| new Function() | Pre-compiled functions |
| setTimeout(string) | Use function reference |
| script injection | CSP script-src 'self' |
| Attack Vector | Mitigation |
|---|---|
| Unvalidated fetch | URL allowlist |
| Image beacons | CSP img-src |
| DNS prefetch | CSP prefetch-src |
| Form action | CSP form-action |
// Prevent extension pages from being framed
if (window !== window.top) {
document.body.innerHTML = '';
throw new Error('Framing not allowed');
}
<all_urls> without justification