Skill
Community

Extension Security

Install
1
Install the plugin
$
npx claudepluginhub arustydev/agents --plugin browser-extension-dev

Want just this skill?

Then install: npx claudepluginhub u/[userId]/[slug]

Description

Comprehensive security guide for browser extensions covering Content Security Policy, permissions model, secure messaging, sandboxing, storage security, and threat mitigation patterns.

Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

Extension Security

Comprehensive security guide for browser extensions covering Content Security Policy, permissions model, secure messaging, sandboxing, storage security, and threat mitigation patterns.

Security Model Overview

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 (CSP)

Manifest V3 Default CSP

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'",
    "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals"
  }
}

CSP Directives Reference

DirectivePurposeRecommended Value
script-srcScript sources'self' only
object-srcPlugin sources'self' or 'none'
style-srcStylesheet sources'self'
img-srcImage sources'self' data: https:
connect-srcXHR/fetch targetsSpecific origins
frame-srciframe sources'self' or 'none'
worker-srcWorker sources'self'

Strict CSP Configuration

{
  "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'"
  }
}

CSP Anti-Patterns

PatternRiskAlternative
'unsafe-eval'Code injectionStatic code, no eval()
'unsafe-inline'XSSExternal scripts only
* wildcardUnrestrictedSpecific domains
data: for scriptsCode injectionBundle scripts
blob: for scriptsCode injectionStatic imports

Dynamic Script Execution

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);

Permissions Model

Permission Types

TypeDeclarationUser PromptBest For
RequiredpermissionsInstall timeCore functionality
Optionaloptional_permissionsRuntimeFeature gates
Hosthost_permissionsInstall/runtimeSite access
Optional hostoptional_host_permissionsRuntimeUser-selected sites

Minimal Permissions Design

{
  "permissions": [
    "storage"
  ],
  "optional_permissions": [
    "tabs",
    "bookmarks"
  ],
  "host_permissions": [],
  "optional_host_permissions": [
    "https://*.example.com/*"
  ]
}

Requesting Optional Permissions

// 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');
  }
}

Permission Escalation Prevention

// 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

Secure Message Passing

Message Types

// 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;
}

Validated Message Handler

// 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 to Background

// 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' });

External Message Security

{
  "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;
  }
);

Port-Based Communication

// 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);
  });
});

Sandboxing and Isolation

Content Script Isolation

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

Protecting Against Page Manipulation

// 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 for Untrusted Content

{
  "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 Security

{
  "web_accessible_resources": [
    {
      "resources": ["injected.js"],
      "matches": ["https://specific-site.com/*"],
      "use_dynamic_url": true
    }
  ]
}

Security considerations:

  • Only expose necessary resources
  • Use use_dynamic_url: true for fingerprinting protection
  • Limit matches to specific sites
  • Avoid exposing sensitive scripts

Storage Security

Storage Types and Security

TypeEncryptionSyncQuotaBest For
storage.localNoneNo10MBLarge data, local-only
storage.syncTransit onlyYes100KBSettings, cross-device
storage.sessionMemory onlyNo10MBTemporary, sensitive

Encrypting Sensitive Data

// 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));
  }
}

Session Storage for Sensitive Data

// 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;
}

Input Validation

URL Validation

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;
  }
}

JSON Schema Validation

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;
}

HTML Sanitization

// 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
  });
}

Network Security

Fetch with Validation

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);
  }
}

CORS and Credentials

// 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'
  });
}

Threat Mitigation

Cross-Site Scripting (XSS)

Attack VectorMitigation
innerHTMLUse textContent or sanitize
eval()Disallow via CSP
URL parametersValidate and sanitize
postMessageVerify origin
DOM clobberingUse unique IDs

Code Injection

Attack VectorMitigation
eval()Static code only
new Function()Pre-compiled functions
setTimeout(string)Use function reference
script injectionCSP script-src 'self'

Data Exfiltration

Attack VectorMitigation
Unvalidated fetchURL allowlist
Image beaconsCSP img-src
DNS prefetchCSP prefetch-src
Form actionCSP form-action

Clickjacking

// Prevent extension pages from being framed
if (window !== window.top) {
  document.body.innerHTML = '';
  throw new Error('Framing not allowed');
}

Security Audit Checklist

Manifest Review

  • No <all_urls> without justification
  • Minimum necessary permissions
  • Optional permissions for non-core features
  • CSP explicitly defined
  • web_accessible_resources limited

Code Review

  • No eval(), new Function(), or setTimeout(string)
  • No innerHTML with untrusted data
  • All external URLs validated
  • Message senders verified
  • Input validation on all external data
  • Sensitive data encrypted or in session storage

Network Security

  • HTTPS only for external requests
  • No credentials sent to third parties
  • Content-Type validated on responses
  • Request timeouts configured

Storage Security

  • Sensitive data encrypted
  • Temporary data in session storage
  • No secrets in storage.sync
  • Storage quotas handled

Related Resources

  • extension-anti-patterns skill: Common security mistakes to avoid
  • validate-extension command: Automated security validation
  • store-submission-reviewer agent: Pre-submission security checks
Stats
Stars6
Forks2
Last CommitMar 18, 2026

Similar Skills