Compare commits

...

2 Commits

Author SHA1 Message Date
tsukino
e099f0b4e9 feat: add plugin permission control system
- Add PermissionManager for runtime host permission management
- Update ConfirmPopup to request host permissions during plugin approval
- Add Options page Permissions tab to view and revoke granted permissions
- Track granted origins per managed window for cleanup on close
- Update manifest to use optional_host_permissions with <all_urls>
- Add extractOrigins() to collect API hosts and page URLs from plugin config
- Implement permission cleanup when window closes or plugin execution fails
2026-01-06 23:08:14 +08:00
tsukino
9c0b12da2c docs: update CLAUDE.md with permission system and new entry points
- Document Options page and ConfirmPopup entry points
- Add ConfirmationManager class documentation
- Add Permission Validation System section
- Document PluginConfig and RequestPermission interfaces
- Update manifest permissions (contextMenus, options_page)
- Update webpack entry points list
- Add Spotify plugin to demo files (PR #210)
- Document array field access fix (PR #212)
- Add new message types documentation
- Update Known Issues section
2026-01-06 19:30:43 +08:00
11 changed files with 1944 additions and 147 deletions

182
CLAUDE.md
View File

@@ -78,7 +78,7 @@ cd packages/extension && npm run dev
## Extension Architecture Overview
### Extension Entry Points
The extension has 5 main entry points defined in `webpack.config.js`:
The extension has 7 main entry points defined in `webpack.config.js`:
#### 1. **Background Service Worker** (`src/entries/Background/index.ts`)
Core responsibilities:
@@ -170,6 +170,25 @@ Isolated React component for background processing:
- **Lifecycle**: Created dynamically by background script, reused if exists
- Entry point: `offscreen.html`
#### 7. **Options Page** (`src/entries/Options/index.tsx`)
Extension settings page for user configuration:
- **Log Level Control**: Configure logging verbosity (DEBUG, INFO, WARN, ERROR)
- **IndexedDB Storage**: Settings persisted via `logLevelStorage.ts` utility
- **Live Updates**: Changes take effect immediately without restart
- **Styling**: Custom SCSS with radio button group design
- Access: Right-click extension icon → Options, or `chrome://extensions` → Details → Extension options
- Entry point: `options.html`
#### 8. **ConfirmPopup** (`src/entries/ConfirmPopup/index.tsx`)
Permission confirmation dialog for plugin execution:
- **Plugin Info Display**: Shows plugin name, description, version, author
- **Permission Review**: Lists allowed network requests and URLs
- **User Controls**: Allow/Deny buttons with keyboard shortcuts (Enter/Esc)
- **Security**: Validates plugin config before execution
- **Timeout**: 60-second auto-deny if no response
- Opened by: `ConfirmationManager` when executing untrusted plugins
- Entry point: `confirmPopup.html`
### Key Classes
#### **WindowManager** (`src/background/WindowManager.ts`)
@@ -228,6 +247,64 @@ const proof = await prove(
);
```
#### **ConfirmationManager** (`src/background/ConfirmationManager.ts`)
Manages plugin execution confirmation dialogs:
- **Permission Flow**: Opens confirmation popup before plugin execution
- **User Approval**: Displays plugin info and permissions for user review
- **Timeout Handling**: 60-second timeout auto-denies if no response
- **Window Tracking**: Tracks confirmation popup windows by request ID
- **Singleton Pattern**: Exported as `confirmationManager` instance
Key methods:
- `requestConfirmation(config, requestId)`: Opens confirmation popup, returns Promise<boolean>
- `handleConfirmationResponse(requestId, allowed)`: Processes user's allow/deny decision
- `hasPendingConfirmation()`: Check if a confirmation is in progress
#### **PermissionManager** (`src/background/PermissionManager.ts`)
Manages runtime host permission requests and revocation:
- **Runtime Permissions**: Requests host permissions from browser at plugin execution time
- **Permission Extraction**: Extracts origin patterns from plugin's `config.requests`
- **Concurrent Execution Tracking**: Tracks which permissions are in use by active plugins
- **Automatic Revocation**: Removes permissions when no longer needed
- **Singleton Pattern**: Exported as `permissionManager` instance
Key methods:
- `extractOrigins(requests)`: Extract origin patterns from RequestPermission array
- `extractPermissionPatterns(requests)`: Extract patterns with host and pathname for display
- `formatForDisplay(requests)`: Format as `host + pathname` strings for UI
- `requestPermissions(origins)`: Request permissions from browser, tracks usage
- `removePermissions(origins)`: Revoke permissions if no longer in use
- `hasPermissions(origins)`: Check if permissions are already granted
**Plugin Execution Flow with Permissions:**
1. User clicks "Allow" in ConfirmPopup
2. PermissionManager extracts origins from `config.requests`
3. Browser shows native permission prompt
4. If granted, plugin executes in offscreen document
5. After execution (success or error), permissions are revoked
### Permission Validation System
The extension implements a permission control system (PR #211) that validates plugin operations:
#### **Permission Validator** (`src/offscreen/permissionValidator.ts`)
Validates plugin API calls against declared permissions:
- `validateProvePermission()`: Checks if prove() call matches declared `requests` permissions
- `validateOpenWindowPermission()`: Checks if openWindow() call matches declared `urls` permissions
- `deriveProxyUrl()`: Derives default proxy URL from verifier URL
- `matchesPathnamePattern()`: URLPattern-based pathname matching with wildcards
**Validation Flow:**
1. Plugin declares permissions in `config.requests` and `config.urls`
2. Before `prove()` or `openWindow()` executes, validator checks permissions
3. If no matching permission found, throws descriptive error
4. Error includes list of declared permissions for debugging
**URLPattern Syntax:**
- Exact paths: `/1.1/users/show.json`
- Single-segment wildcard: `/api/*/data` (matches `/api/v1/data`)
- Multi-segment wildcard: `/api/**` (matches `/api/v1/users/123`)
### State Management
Redux store located in `src/reducers/index.tsx`:
- **App State Interface**: `{ message: string, count: number }`
@@ -282,6 +359,17 @@ Content Script: Renders plugin UI from DOM JSON
- Content script validates origin (`event.origin === window.location.origin`)
- URL validation using `validateUrl()` utility before window creation
- Request interception limited to managed windows only
- Plugin permission validation before prove() and openWindow() calls
**Additional Message Types:**
- `PLUGIN_CONFIRM_RESPONSE` → User response (allow/deny) from confirmation popup
- `PLUGIN_UI_CLICK` → Button click event from plugin UI in content script
- `EXTRACT_CONFIG` → Request to extract plugin config from code
- `EXEC_CODE_OFFSCREEN` → Execute plugin code in offscreen context
- `RENDER_PLUGIN_UI` → Render plugin UI from DOM JSON in content script
- `OFFSCREEN_LOG` → Log forwarding from offscreen to page context
- `REQUEST_HOST_PERMISSIONS` → Request browser host permissions for origins
- `REMOVE_HOST_PERMISSIONS` → Revoke browser host permissions for origins
### TLSN Overlay Feature
@@ -298,7 +386,7 @@ The overlay is a full-screen modal showing intercepted requests:
### Build Configuration
**Webpack 5 Setup** (`webpack.config.js`):
- **Entry Points**: popup, background, contentScript, content, offscreen
- **Entry Points**: popup, background, contentScript, content, offscreen, devConsole, options, confirmPopup
- **Output**: `build/` directory with `[name].bundle.js` pattern
- **Loaders**:
- `ts-loader` - TypeScript compilation (transpileOnly in dev)
@@ -310,7 +398,7 @@ The overlay is a full-screen modal showing intercepted requests:
- `ReactRefreshWebpackPlugin` - Hot module replacement (dev only)
- `CleanWebpackPlugin` - Cleans build directory
- `CopyWebpackPlugin` - Copies manifest, icons, CSS files
- `HtmlWebpackPlugin` - Generates popup.html and offscreen.html
- `HtmlWebpackPlugin` - Generates popup.html, offscreen.html, devConsole.html, options.html, confirmPopup.html
- `TerserPlugin` - Code minification (production only)
- **Dev Server** (`utils/webserver.js`):
- Port: 3000 (configurable via `PORT` env var)
@@ -332,10 +420,19 @@ Defined in `src/manifest.json`:
- `activeTab` - Access active tab information
- `tabs` - Tab management (create, query, update)
- `windows` - Window management (create, track, remove)
- `host_permissions: ["<all_urls>"]` - Access all URLs for request interception
- `contextMenus` - Create context menu items (Developer Console access)
- `optional_host_permissions: ["https://*/*", "http://*/*"]` - Runtime-requested host permissions
- `content_scripts` - Inject into all HTTP/HTTPS pages
- `web_accessible_resources` - Make content.bundle.js, CSS, and icons accessible to pages
- `web_accessible_resources` - Make content.bundle.js, CSS, icons, and WASM files accessible to pages
- `content_security_policy` - Allow WASM execution (`wasm-unsafe-eval`)
- `options_page` - Extension settings page (`options.html`)
**Runtime Host Permissions:**
The extension uses `optional_host_permissions` instead of blanket `host_permissions` for improved privacy:
- No host permissions granted by default
- Permissions are requested at runtime based on plugin's `config.requests`
- Permissions are revoked immediately after plugin execution completes
- Uses browser's native permission prompt for user consent
### TypeScript Configuration
@@ -407,6 +504,47 @@ Defined in `src/manifest.json`:
## Plugin SDK Package (`packages/plugin-sdk`)
### Plugin Configuration
Plugins must export a `config` object with the following structure:
```typescript
interface PluginConfig {
name: string; // Display name
description: string; // What the plugin does
version?: string; // Optional version string
author?: string; // Optional author name
requests?: RequestPermission[]; // Allowed HTTP requests for prove()
urls?: string[]; // Allowed URLs for openWindow()
}
interface RequestPermission {
method: string; // HTTP method (GET, POST, etc.)
host: string; // Target hostname
pathname: string; // URL path pattern (supports wildcards)
verifierUrl: string; // Verifier server URL
proxyUrl?: string; // Optional proxy URL (derived from verifierUrl if omitted)
}
```
**Example config with permissions:**
```javascript
const config = {
name: 'Twitter Plugin',
description: 'Generate TLS proofs for Twitter profile data',
version: '1.0.0',
author: 'TLSN Team',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.tlsnotary.org'
}
],
urls: ['https://x.com/*']
};
```
### Host Class API
The SDK provides a `Host` class for sandboxed plugin execution with capability injection:
@@ -422,6 +560,9 @@ const host = new Host({
// Execute plugin code
await host.executePlugin(pluginCode, { eventEmitter });
// Extract plugin config without executing
const config = await host.getPluginConfig(pluginCode);
```
**Capabilities injected into plugin environment:**
@@ -471,6 +612,7 @@ const ranges = parser.ranges.body('screen_name', { type: 'json', hideKey: true }
- Handle chunked transfer encoding
- Extract header ranges with case-insensitive names
- Extract JSON field ranges (top-level only)
- Array field access returns range for entire array (PR #212)
- Regex-based body pattern matching
- Track byte offsets for TLSNotary selective disclosure
@@ -486,6 +628,20 @@ const ranges = parser.ranges.body('screen_name', { type: 'json', hideKey: true }
- Reactive rendering: `main()` function called whenever hook state changes
- Force re-render: `main(true)` can be called to force UI re-render even if state hasn't changed (used on content script initialization)
### Config Extraction
The SDK provides utilities to extract plugin config without full execution:
```typescript
import { extractConfig } from '@tlsn/plugin-sdk';
// Fast extraction using regex (name/description only)
const basicConfig = await extractConfig(pluginCode);
// Full extraction using QuickJS sandbox (includes permissions)
const host = new Host({ /* ... */ });
const fullConfig = await host.getPluginConfig(pluginCode);
```
### Build Configuration
- **Vite**: Builds isomorphic package for Node.js and browser
- **TypeScript**: Strict mode with full type declarations
@@ -586,12 +742,18 @@ logger.setLevel(LogLevel.WARN);
[HH:MM:SS] [LEVEL] message
```
**Log Level Storage** (extension only):
The extension persists log level in IndexedDB via `src/utils/logLevelStorage.ts`:
- `getStoredLogLevel()`: Retrieve saved log level (defaults to WARN)
- `setStoredLogLevel(level)`: Save log level to IndexedDB
- Uses `idb-keyval` for simple key-value storage
## Demo Package (`packages/demo`)
Docker-based demo environment for testing plugins:
**Files:**
- `twitter.js`, `swissbank.js` - Example plugin files
- `twitter.js`, `swissbank.js`, `spotify.js` - Example plugin files (PR #210 added Spotify)
- `docker-compose.yml` - Docker services configuration
- `nginx.conf` - Reverse proxy configuration
- `start.sh` - Setup script with URL templating
@@ -659,12 +821,8 @@ Parser tests (`packages/plugin-sdk/src/parser.test.ts`) use redacted sensitive d
### Known Issues
⚠️ **Legacy Code Warning**: `src/entries/utils.ts` contains imports from non-existent files:
- `Background/rpc.ts` (removed in refactor)
- `SidePanel/types.ts` (removed in refactor)
- Functions: `pushToRedux()`, `openSidePanel()`, `waitForEvent()`
- **Status**: Dead code, not used by current entry points
- **Action**: Remove this file or refactor if functionality needed
- **Nested JSON field access**: Parser does not yet support paths like `"user.profile.name"`
- **Duplicate CLAUDE.md**: `packages/extension/CLAUDE.md` contains outdated documentation; use root CLAUDE.md instead
## Websockify Integration

View File

@@ -2,9 +2,14 @@ import browser from 'webextension-polyfill';
import { logger } from '@tlsn/common';
import { PluginConfig } from '@tlsn/plugin-sdk/src/types';
interface ConfirmationResult {
allowed: boolean;
grantedOrigins: string[];
}
interface PendingConfirmation {
requestId: string;
resolve: (allowed: boolean) => void;
resolve: (result: ConfirmationResult) => void;
reject: (error: Error) => void;
windowId?: number;
timeoutId?: ReturnType<typeof setTimeout>;
@@ -34,15 +39,16 @@ export class ConfirmationManager {
/**
* Request confirmation from the user for plugin execution.
* Opens a popup window displaying plugin details and waits for user response.
* The popup also handles requesting host permissions from the browser.
*
* @param config - Plugin configuration (can be null for unknown plugins)
* @param requestId - Unique ID to correlate the confirmation request
* @returns Promise that resolves to true (allowed) or false (denied)
* @returns Promise that resolves to ConfirmationResult with allowed status and granted origins
*/
async requestConfirmation(
config: PluginConfig | null,
requestId: string,
): Promise<boolean> {
): Promise<ConfirmationResult> {
// Check if there's already a pending confirmation
if (this.pendingConfirmations.size > 0) {
logger.warn(
@@ -54,7 +60,7 @@ export class ConfirmationManager {
// Build URL with plugin info as query params
const popupUrl = this.buildPopupUrl(config, requestId);
return new Promise<boolean>(async (resolve, reject) => {
return new Promise<ConfirmationResult>(async (resolve, reject) => {
try {
// Create the confirmation popup window
const window = await browser.windows.create({
@@ -77,7 +83,7 @@ export class ConfirmationManager {
if (pending) {
logger.debug('[ConfirmationManager] Confirmation timed out');
this.cleanup(requestId);
resolve(false); // Treat timeout as denial
resolve({ allowed: false, grantedOrigins: [] }); // Treat timeout as denial
}
}, this.CONFIRMATION_TIMEOUT_MS);
@@ -109,8 +115,13 @@ export class ConfirmationManager {
*
* @param requestId - The request ID to match
* @param allowed - Whether the user allowed execution
* @param grantedOrigins - Origins that were granted by the browser (from popup's permission request)
*/
handleConfirmationResponse(requestId: string, allowed: boolean): void {
handleConfirmationResponse(
requestId: string,
allowed: boolean,
grantedOrigins: string[] = [],
): void {
const pending = this.pendingConfirmations.get(requestId);
if (!pending) {
logger.warn(
@@ -120,11 +131,11 @@ export class ConfirmationManager {
}
logger.debug(
`[ConfirmationManager] Received response for ${requestId}: ${allowed ? 'allowed' : 'denied'}`,
`[ConfirmationManager] Received response for ${requestId}: ${allowed ? 'allowed' : 'denied'}, origins: ${grantedOrigins.length}`,
);
// Resolve the promise
pending.resolve(allowed);
// Resolve the promise with the result
pending.resolve({ allowed, grantedOrigins });
// Close popup window if still open
if (pending.windowId) {
@@ -154,7 +165,7 @@ export class ConfirmationManager {
logger.debug(
`[ConfirmationManager] Treating window close as denial for request: ${requestId}`,
);
pending.resolve(false); // Treat close as denial
pending.resolve({ allowed: false, grantedOrigins: [] }); // Treat close as denial
this.cleanup(requestId);
break;
}

View File

@@ -0,0 +1,306 @@
import browser from 'webextension-polyfill';
import { logger } from '@tlsn/common';
import { RequestPermission } from '@tlsn/plugin-sdk/src/types';
/**
* Represents a permission pattern with metadata for display.
*/
interface PermissionPattern {
/** Browser permission pattern (e.g., "https://api.x.com/*") */
origin: string;
/** Original host from config */
host: string;
/** Original pathname pattern from config */
pathname: string;
}
/**
* Manages runtime host permissions for plugin execution.
*
* The extension uses optional_host_permissions instead of blanket host_permissions.
* Permissions are requested before plugin execution and revoked after completion.
*/
export class PermissionManager {
/** Track permissions currently in use by active plugin executions */
private activePermissions: Map<string, number> = new Map();
/**
* Extract permission patterns from plugin's request permissions.
* Uses req.host and req.pathname to build patterns.
*
* Note: Browser permissions API only supports origin-level permissions,
* but we track pathname for display and internal validation.
*
* @example
* // Input: { host: "api.x.com", pathname: "/1.1/users/*" }
* // Output: { origin: "https://api.x.com/*", host: "api.x.com", pathname: "/1.1/users/*" }
*/
extractPermissionPatterns(
requests: RequestPermission[],
): PermissionPattern[] {
const patterns: PermissionPattern[] = [];
const seenOrigins = new Set<string>();
for (const req of requests) {
// Build origin pattern from req.host
const origin = `https://${req.host}/*`;
if (!seenOrigins.has(origin)) {
seenOrigins.add(origin);
patterns.push({
origin,
host: req.host,
pathname: req.pathname,
});
}
// Also add the verifier URL host if different
try {
const verifierUrl = new URL(req.verifierUrl);
const verifierOrigin = `${verifierUrl.protocol}//${verifierUrl.host}/*`;
if (!seenOrigins.has(verifierOrigin)) {
seenOrigins.add(verifierOrigin);
patterns.push({
origin: verifierOrigin,
host: verifierUrl.host,
pathname: '/*', // Verifier needs full access
});
}
} catch {
// Invalid verifier URL, skip
}
}
return patterns;
}
/**
* Extract just the origin patterns for browser.permissions API.
* Uses req.host to build origin-level patterns.
*/
extractOrigins(requests: RequestPermission[]): string[] {
return this.extractPermissionPatterns(requests).map((p) => p.origin);
}
/**
* Format permissions for display in UI.
* Shows both host and pathname for user clarity.
*/
formatForDisplay(requests: RequestPermission[]): string[] {
return requests.map((req) => `${req.host}${req.pathname}`);
}
/**
* Request permissions for the given host patterns.
* Tracks active permissions to handle concurrent plugin executions.
*
* @returns true if all permissions granted, false otherwise
*/
async requestPermissions(origins: string[]): Promise<boolean> {
if (origins.length === 0) return true;
try {
// Check if already have permissions
const alreadyGranted = await this.hasPermissions(origins);
if (alreadyGranted) {
logger.debug(
'[PermissionManager] Permissions already granted for:',
origins,
);
// Track that we're using these permissions
this.trackPermissionUsage(origins, 1);
return true;
}
// Request new permissions
const granted = await browser.permissions.request({ origins });
logger.info(
`[PermissionManager] Permissions ${granted ? 'granted' : 'denied'} for:`,
origins,
);
if (granted) {
this.trackPermissionUsage(origins, 1);
}
return granted;
} catch (error) {
logger.error('[PermissionManager] Failed to request permissions:', error);
return false;
}
}
/**
* Check if an origin is removable (not a manifest-defined wildcard pattern).
* Manifest patterns like "http://*\/*" and "https://*\/*" cannot be removed.
*/
private isRemovableOrigin(origin: string): boolean {
// Manifest-defined patterns that cannot be removed
const manifestPatterns = ['http://*/*', 'https://*/*', '<all_urls>'];
if (manifestPatterns.includes(origin)) {
return false;
}
// Check if host contains wildcards (not removable)
try {
const url = new URL(origin.replace('/*', '/'));
if (url.hostname.includes('*')) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Remove permissions for the given host patterns.
* Only removes if no other plugin execution is using them.
* Filters out non-removable (manifest-defined) patterns.
*/
async removePermissions(origins: string[]): Promise<boolean> {
logger.info('[PermissionManager] removePermissions called with:', origins);
if (origins.length === 0) {
logger.info('[PermissionManager] No origins to remove (empty array)');
return true;
}
// Decrement usage count
this.trackPermissionUsage(origins, -1);
// Only remove permissions that are no longer in use AND are removable
logger.info('[PermissionManager] Filtering origins for removal...');
const originsToRemove = origins.filter((origin) => {
const count = this.activePermissions.get(origin) || 0;
const notInUse = count <= 0;
const removable = this.isRemovableOrigin(origin);
logger.info(
`[PermissionManager] Origin "${origin}": count=${count}, notInUse=${notInUse}, removable=${removable}`,
);
if (!removable) {
logger.debug(
`[PermissionManager] Skipping non-removable origin: ${origin}`,
);
}
return notInUse && removable;
});
logger.info(
`[PermissionManager] After filtering: ${originsToRemove.length} origins to remove:`,
originsToRemove,
);
if (originsToRemove.length === 0) {
logger.info(
'[PermissionManager] No removable permissions to remove from:',
origins,
);
return true;
}
try {
// Verify which permissions actually exist before removal
const existingPermissions = await browser.permissions.getAll();
logger.info(
'[PermissionManager] Current permissions before removal:',
existingPermissions.origins,
);
// Filter to only origins that actually exist
const existingOrigins = new Set(existingPermissions.origins || []);
const originsActuallyExist = originsToRemove.filter((o) =>
existingOrigins.has(o),
);
if (originsActuallyExist.length === 0) {
logger.info(
'[PermissionManager] None of the origins to remove actually exist, skipping',
);
return true;
}
logger.info(
'[PermissionManager] Calling browser.permissions.remove() for:',
originsActuallyExist,
);
const removed = await browser.permissions.remove({
origins: originsActuallyExist,
});
logger.info(
`[PermissionManager] browser.permissions.remove() returned: ${removed}`,
);
// Log permissions after removal
const afterPermissions = await browser.permissions.getAll();
logger.info(
'[PermissionManager] Permissions after removal:',
afterPermissions.origins,
);
return removed;
} catch (error) {
// Handle "required permissions" error gracefully
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
`[PermissionManager] browser.permissions.remove() threw error: ${errorMessage}`,
);
if (errorMessage.includes('required permissions')) {
logger.warn(
'[PermissionManager] Some permissions are required and cannot be removed:',
originsToRemove,
);
return true; // Don't treat as failure
}
logger.error('[PermissionManager] Failed to remove permissions:', error);
return false;
}
}
/**
* Check if permissions are already granted for the given origins.
*/
async hasPermissions(origins: string[]): Promise<boolean> {
if (origins.length === 0) return true;
try {
return await browser.permissions.contains({ origins });
} catch (error) {
logger.error('[PermissionManager] Failed to check permissions:', error);
return false;
}
}
/**
* Track permission usage for concurrent plugin executions.
* @param origins - Origins to track
* @param delta - +1 for acquire, -1 for release
*/
private trackPermissionUsage(origins: string[], delta: number): void {
for (const origin of origins) {
const current = this.activePermissions.get(origin) || 0;
const newCount = current + delta;
if (newCount <= 0) {
this.activePermissions.delete(origin);
} else {
this.activePermissions.set(origin, newCount);
}
}
}
/**
* Get the number of active usages for an origin.
* Useful for debugging and testing.
*/
getActiveUsageCount(origin: string): number {
return this.activePermissions.get(origin) || 0;
}
}
// Export singleton instance
export const permissionManager = new PermissionManager();

View File

@@ -116,6 +116,7 @@ export class WindowManager implements IWindowManager {
overlayVisible: false,
pluginUIVisible: false,
showOverlayWhenReady: config.showOverlay !== false, // Default: true
grantedOrigins: [],
};
this.windows.set(config.id, managedWindow);
@@ -363,6 +364,38 @@ export class WindowManager implements IWindowManager {
return window?.headers || [];
}
/**
* Set granted origins for a window (for cleanup on close)
*
* @param windowId - Chrome window ID
* @param origins - Array of origin patterns that were granted
*/
setGrantedOrigins(windowId: number, origins: string[]): void {
const window = this.windows.get(windowId);
if (!window) {
logger.error(
`[WindowManager] Cannot set granted origins for non-existent window: ${windowId}`,
);
return;
}
window.grantedOrigins = origins;
logger.debug(
`[WindowManager] Set granted origins for window ${windowId}:`,
origins,
);
}
/**
* Get granted origins for a window
*
* @param windowId - Chrome window ID
* @returns Array of granted origin patterns
*/
getGrantedOrigins(windowId: number): string[] {
const window = this.windows.get(windowId);
return window?.grantedOrigins || [];
}
async showPluginUI(
windowId: number,
json: any,

View File

@@ -1,6 +1,7 @@
import browser from 'webextension-polyfill';
import { WindowManager } from '../../background/WindowManager';
import { confirmationManager } from '../../background/ConfirmationManager';
import { permissionManager } from '../../background/PermissionManager';
import type { PluginConfig } from '@tlsn/plugin-sdk/src/types';
import type {
InterceptedRequest,
@@ -21,6 +22,187 @@ getStoredLogLevel().then((level) => {
// Initialize WindowManager for multi-window support
const windowManager = new WindowManager();
// Temporary storage for granted origins pending window creation
// When a plugin is executed, we store the granted origins here
// Then when the plugin opens a window, we associate these origins with that window
let pendingGrantedOrigins: string[] = [];
// =============================================================================
// DYNAMIC WEBREQUEST LISTENER MANAGEMENT
// =============================================================================
// Track active dynamic listeners by origin pattern
// We need to store handler references to remove them later
type ListenerHandlers = {
onBeforeRequest: (
details: browser.WebRequest.OnBeforeRequestDetailsType,
) => void;
onBeforeSendHeaders: (
details: browser.WebRequest.OnBeforeSendHeadersDetailsType,
) => void;
};
const dynamicListeners = new Map<string, ListenerHandlers>();
/**
* Handler for onBeforeRequest - intercepts request body
*/
function createOnBeforeRequestHandler() {
return (details: browser.WebRequest.OnBeforeRequestDetailsType) => {
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const request: InterceptedRequest = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: Date.now(),
tabId: details.tabId,
requestBody: details.requestBody,
};
logger.debug(`[webRequest] Intercepted request: ${details.url}`);
windowManager.addRequest(managedWindow.id, request);
}
};
}
/**
* Handler for onBeforeSendHeaders - intercepts request headers
*/
function createOnBeforeSendHeadersHandler() {
return (details: browser.WebRequest.OnBeforeSendHeadersDetailsType) => {
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const header: InterceptedRequestHeader = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: details.timeStamp,
type: details.type,
requestHeaders: details.requestHeaders || [],
tabId: details.tabId,
};
logger.debug(`[webRequest] Intercepted headers for: ${details.url}`);
windowManager.addHeader(managedWindow.id, header);
}
};
}
/**
* Register dynamic webRequest listeners for specific origin patterns.
* Must be called AFTER permissions are granted for those origins.
*/
function registerDynamicListeners(origins: string[]): void {
logger.info(`[webRequest] registerDynamicListeners called with:`, origins);
for (const origin of origins) {
// Skip if already registered
if (dynamicListeners.has(origin)) {
logger.debug(`[webRequest] Listener already registered for: ${origin}`);
continue;
}
logger.info(`[webRequest] Registering listener for: "${origin}"`);
const onBeforeRequestHandler = createOnBeforeRequestHandler();
const onBeforeSendHeadersHandler = createOnBeforeSendHeadersHandler();
try {
browser.webRequest.onBeforeRequest.addListener(
onBeforeRequestHandler,
{ urls: [origin] },
['requestBody', 'extraHeaders'],
);
browser.webRequest.onBeforeSendHeaders.addListener(
onBeforeSendHeadersHandler,
{ urls: [origin] },
['requestHeaders', 'extraHeaders'],
);
dynamicListeners.set(origin, {
onBeforeRequest: onBeforeRequestHandler,
onBeforeSendHeaders: onBeforeSendHeadersHandler,
});
logger.info(
`[webRequest] Successfully registered listener for: ${origin}`,
);
} catch (error) {
logger.error(
`[webRequest] Failed to register listener for ${origin}:`,
error,
);
}
}
}
/**
* Unregister dynamic webRequest listeners for specific origin patterns.
* Should be called when permissions are revoked.
*/
function unregisterDynamicListeners(origins: string[]): void {
logger.info(`[webRequest] unregisterDynamicListeners called with:`, origins);
logger.info(
`[webRequest] Current dynamicListeners Map keys:`,
Array.from(dynamicListeners.keys()),
);
for (const origin of origins) {
logger.info(`[webRequest] Looking for listener with key: "${origin}"`);
const handlers = dynamicListeners.get(origin);
if (!handlers) {
logger.warn(
`[webRequest] No listener found for: "${origin}" - available keys: ${Array.from(dynamicListeners.keys()).join(', ')}`,
);
continue;
}
logger.info(`[webRequest] Found handlers for: ${origin}, removing...`);
try {
browser.webRequest.onBeforeRequest.removeListener(
handlers.onBeforeRequest,
);
logger.info(
`[webRequest] Removed onBeforeRequest listener for: ${origin}`,
);
browser.webRequest.onBeforeSendHeaders.removeListener(
handlers.onBeforeSendHeaders,
);
logger.info(
`[webRequest] Removed onBeforeSendHeaders listener for: ${origin}`,
);
dynamicListeners.delete(origin);
logger.info(
`[webRequest] Successfully unregistered all listeners for: ${origin}`,
);
} catch (error) {
logger.error(
`[webRequest] Failed to unregister listener for ${origin}:`,
error,
);
}
}
logger.info(
`[webRequest] After unregister, remaining keys:`,
Array.from(dynamicListeners.keys()),
);
}
/**
* Unregister all dynamic webRequest listeners.
*/
function unregisterAllDynamicListeners(): void {
const origins = Array.from(dynamicListeners.keys());
unregisterDynamicListeners(origins);
}
// Create context menu for Developer Console - only for extension icon
browser.contextMenus.create({
id: 'developer-console',
@@ -43,88 +225,88 @@ browser.runtime.onInstalled.addListener((details) => {
logger.info('Extension installed/updated:', details.reason);
});
// Set up webRequest listener to intercept all requests
browser.webRequest.onBeforeRequest.addListener(
(details) => {
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(details.tabId);
// NOTE: Static webRequest listeners removed - using dynamic listeners instead
// Dynamic listeners are registered when plugin permissions are granted
// and unregistered when permissions are revoked
if (managedWindow && details.tabId !== undefined) {
const request: InterceptedRequest = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: Date.now(),
tabId: details.tabId,
requestBody: details.requestBody,
};
// if (details.requestBody) {
// console.log(details.requestBody);
// }
// Add request to window's request history
windowManager.addRequest(managedWindow.id, request);
}
},
{ urls: ['<all_urls>'] },
['requestBody', 'extraHeaders'],
);
browser.webRequest.onBeforeSendHeaders.addListener(
(details) => {
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const header: InterceptedRequestHeader = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: details.timeStamp,
type: details.type,
requestHeaders: details.requestHeaders || [],
tabId: details.tabId,
};
// Add request to window's request history
windowManager.addHeader(managedWindow.id, header);
}
},
{ urls: ['<all_urls>'] },
['requestHeaders', 'extraHeaders'],
);
// Listen for window removal
// Listen for window removal - clean up permissions and listeners
browser.windows.onRemoved.addListener(async (windowId) => {
const managedWindow = windowManager.getWindow(windowId);
if (managedWindow) {
logger.debug(
`Managed window closed: ${managedWindow.uuid} (ID: ${windowId})`,
);
// Get granted origins before closing the window
const grantedOrigins = managedWindow.grantedOrigins || [];
logger.info(
`[Permission Cleanup] Window ${windowId} grantedOrigins:`,
grantedOrigins,
);
logger.info(
`[Permission Cleanup] Current dynamicListeners keys:`,
Array.from(dynamicListeners.keys()),
);
// Clean up permissions and listeners for this window
if (grantedOrigins.length > 0) {
logger.info(
`[Permission Cleanup] Window ${windowId} closed, cleaning up ${grantedOrigins.length} origins:`,
grantedOrigins,
);
// Step 1: Unregister dynamic webRequest listeners FIRST
logger.info(
'[Permission Cleanup] Step 1: Unregistering dynamic listeners...',
);
unregisterDynamicListeners(grantedOrigins);
logger.info(
'[Permission Cleanup] Step 1 complete. Remaining dynamicListeners:',
Array.from(dynamicListeners.keys()),
);
// Step 2: Revoke host permissions AFTER listeners are removed
logger.info('[Permission Cleanup] Step 2: Revoking host permissions...');
try {
const removed =
await permissionManager.removePermissions(grantedOrigins);
logger.info(
`[Permission Cleanup] Step 2 complete. Permissions removed: ${removed}`,
);
} catch (error) {
logger.error(
`[Permission Cleanup] Step 2 FAILED for window ${windowId}:`,
error,
);
}
} else {
logger.info(
`[Permission Cleanup] Window ${windowId} has no granted origins to clean up`,
);
}
await windowManager.closeWindow(windowId);
}
});
// Listen for tab updates to show overlay when tab is ready (Task 3.4)
// Listen for tab updates to show overlay when tab is ready
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
// Only act when tab becomes complete
if (changeInfo.status !== 'complete') {
return;
}
// Check if this tab belongs to a managed window
// Check if this tab belongs to a managed window for overlay handling
const managedWindow = windowManager.getWindowByTabId(tabId);
if (!managedWindow) {
return;
}
// If overlay should be shown but isn't visible yet, show it now
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
logger.debug(
`Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
if (managedWindow) {
// If overlay should be shown but isn't visible yet, show it now
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
logger.debug(
`Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
}
}
});
@@ -162,6 +344,7 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
confirmationManager.handleConfirmationResponse(
request.requestId,
request.allowed,
request.grantedOrigins || [],
);
return true;
}
@@ -182,12 +365,12 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
// Continue with null config - user will see "Unknown Plugin" warning
}
// Step 2: Request user confirmation
// Step 2: Request user confirmation (popup also handles permission request)
const confirmRequestId = `confirm_${Date.now()}_${Math.random()}`;
let userAllowed: boolean;
let confirmResult: { allowed: boolean; grantedOrigins: string[] };
try {
userAllowed = await confirmationManager.requestConfirmation(
confirmResult = await confirmationManager.requestConfirmation(
pluginConfig,
confirmRequestId,
);
@@ -204,8 +387,8 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
}
// Step 3: If user denied, return rejection error
if (!userAllowed) {
logger.info('User rejected plugin execution');
if (!confirmResult.allowed) {
logger.info('User rejected plugin execution or denied permissions');
sendResponse({
success: false,
error: 'User rejected plugin execution',
@@ -213,13 +396,36 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
return;
}
// Step 4: User allowed - proceed with execution
logger.info('User allowed plugin execution, proceeding...');
// Step 4: User allowed and permissions granted - proceed with execution
logger.info(
'User allowed plugin execution, granted origins:',
confirmResult.grantedOrigins,
);
// Ensure offscreen document exists
// Step 4.1: Register dynamic webRequest listeners for granted origins
// This must happen AFTER permissions are granted
if (confirmResult.grantedOrigins.length > 0) {
logger.info(
'[EXEC_CODE] Registering dynamic webRequest listeners for:',
confirmResult.grantedOrigins,
);
registerDynamicListeners(confirmResult.grantedOrigins);
// Store granted origins for association with the window that will be opened
// The OPEN_WINDOW handler will associate these with the new window
pendingGrantedOrigins = confirmResult.grantedOrigins;
logger.info(
'[EXEC_CODE] Stored pendingGrantedOrigins:',
pendingGrantedOrigins,
);
}
// Step 4.2: Execute plugin
// Note: Cleanup happens when the window is closed (windows.onRemoved listener)
// NOT in a finally block here, because EXEC_CODE_OFFSCREEN returns immediately
// when the plugin starts, not when it finishes
await createOffscreenDocument();
// Forward to offscreen document
const response = await chrome.runtime.sendMessage({
type: 'EXEC_CODE_OFFSCREEN',
code: request.code,
@@ -229,6 +435,18 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
sendResponse(response);
} catch (error) {
logger.error('Error executing code:', error);
// Clean up listeners and pending origins if execution failed before window opened
if (pendingGrantedOrigins.length > 0) {
logger.info(
'[EXEC_CODE] Error occurred - cleaning up pending origins:',
pendingGrantedOrigins,
);
unregisterDynamicListeners(pendingGrantedOrigins);
await permissionManager.removePermissions(pendingGrantedOrigins);
pendingGrantedOrigins = [];
}
sendResponse({
success: false,
error:
@@ -240,6 +458,55 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
return true; // Keep message channel open for async response
}
// Handle permission requests from offscreen/content scripts
if (request.type === 'REQUEST_HOST_PERMISSIONS') {
logger.debug('REQUEST_HOST_PERMISSIONS received:', request.origins);
(async () => {
try {
const granted = await permissionManager.requestPermissions(
request.origins,
);
sendResponse({ success: true, granted });
} catch (error) {
logger.error('Failed to request permissions:', error);
sendResponse({
success: false,
error:
error instanceof Error
? error.message
: 'Permission request failed',
});
}
})();
return true;
}
if (request.type === 'REMOVE_HOST_PERMISSIONS') {
logger.debug('REMOVE_HOST_PERMISSIONS received:', request.origins);
(async () => {
try {
const removed = await permissionManager.removePermissions(
request.origins,
);
sendResponse({ success: true, removed });
} catch (error) {
logger.error('Failed to remove permissions:', error);
sendResponse({
success: false,
error:
error instanceof Error
? error.message
: 'Permission removal failed',
});
}
})();
return true;
}
// Handle CLOSE_WINDOW requests
if (request.type === 'CLOSE_WINDOW') {
logger.debug('CLOSE_WINDOW request received:', request.windowId);
@@ -334,6 +601,30 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
logger.debug(`Window registered: ${managedWindow.uuid}`);
// Associate pending granted origins with this window
// These will be cleaned up when the window is closed
logger.info(
`[OPEN_WINDOW] pendingGrantedOrigins at association time:`,
pendingGrantedOrigins,
);
if (pendingGrantedOrigins.length > 0) {
logger.info(
`[OPEN_WINDOW] Associating ${pendingGrantedOrigins.length} origins with window ${windowId}:`,
pendingGrantedOrigins,
);
windowManager.setGrantedOrigins(windowId, pendingGrantedOrigins);
logger.info(
`[OPEN_WINDOW] Successfully associated origins. Window ${windowId} now has grantedOrigins:`,
windowManager.getGrantedOrigins(windowId),
);
// Clear pending origins now that they're associated
pendingGrantedOrigins = [];
} else {
logger.warn(
`[OPEN_WINDOW] No pending origins to associate with window ${windowId}`,
);
}
// Send success response
sendResponse({
type: 'WINDOW_OPENED',

View File

@@ -24,6 +24,48 @@ interface PluginInfo {
urls?: string[];
}
/**
* Extract origin patterns for browser.permissions API and webRequest interception.
*
* Includes:
* - requests[].host: API hosts for webRequest interception (e.g., api.x.com)
* - urls[]: Page URLs for webRequest interception (e.g., x.com/*)
*
* Does NOT include:
* - verifierUrl: Extension connects to verifier directly, doesn't need host permission
*
* NOTE: Page URL permissions may become "required" if they match content_scripts.matches,
* but API host permissions (like api.x.com) should remain revocable.
*
* @param requests - Request permissions from plugin config (for API endpoints)
* @param urls - Page URL patterns from plugin config
*/
function extractOrigins(
requests: RequestPermission[],
urls?: string[],
): string[] {
const origins = new Set<string>();
// Add target API hosts from requests
for (const req of requests) {
origins.add(`https://${req.host}/*`);
}
// Add page URLs for webRequest interception
if (urls) {
for (const urlPattern of urls) {
if (
urlPattern.startsWith('https://') ||
urlPattern.startsWith('http://')
) {
origins.add(urlPattern);
}
}
}
return Array.from(origins);
}
const ConfirmPopup: React.FC = () => {
const [pluginInfo, setPluginInfo] = useState<PluginInfo | null>(null);
const [requestId, setRequestId] = useState<string>('');
@@ -109,16 +151,64 @@ const ConfirmPopup: React.FC = () => {
if (!requestId) return;
try {
let grantedOrigins: string[] = [];
// Request host permissions if plugin has requests or urls defined
// This MUST be done in the popup context (user gesture) for the browser to show the prompt
const hasRequests =
pluginInfo?.requests && pluginInfo.requests.length > 0;
const hasUrls = pluginInfo?.urls && pluginInfo.urls.length > 0;
if (hasRequests || hasUrls) {
const origins = extractOrigins(
pluginInfo?.requests || [],
pluginInfo?.urls,
);
logger.info('Requesting permissions for origins:', origins);
try {
const granted = await browser.permissions.request({ origins });
if (!granted) {
logger.warn('User denied host permissions');
// Send denial response
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: false,
reason: 'Host permissions denied',
});
window.close();
return;
}
grantedOrigins = origins;
logger.info('Host permissions granted:', grantedOrigins);
} catch (permError) {
logger.error('Failed to request permissions:', permError);
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: false,
reason: 'Permission request failed',
});
window.close();
return;
}
}
// Send approval with granted origins
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: true,
grantedOrigins,
});
window.close();
} catch (err) {
logger.error('Failed to send allow response:', err);
}
}, [requestId]);
}, [requestId, pluginInfo]);
const handleDeny = useCallback(async () => {
if (!requestId) return;

View File

@@ -36,10 +36,23 @@ body {
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 1s linear infinite;
&--small {
width: 24px;
height: 24px;
border-width: 2px;
}
&--tiny {
width: 14px;
height: 14px;
border-width: 2px;
display: inline-block;
}
}
&__header {
margin-bottom: 32px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
@@ -53,6 +66,36 @@ body {
}
}
// Tabs
&__tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
&__tab {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #a0a0a0;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: #e8e8e8;
}
&--active {
background: rgba(74, 158, 255, 0.15);
border-color: rgba(74, 158, 255, 0.4);
color: #4a9eff;
}
}
&__content {
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
@@ -186,6 +229,211 @@ body {
font-family: 'Monaco', 'Menlo', monospace;
}
// Permissions styles
&__permissions-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 24px;
color: #a0a0a0;
}
&__permissions-empty {
text-align: center;
padding: 40px 24px;
color: #a0a0a0;
p {
margin-top: 8px;
}
}
&__permissions-empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
&__permissions-empty-hint {
font-size: 13px;
color: #666;
margin-top: 8px;
}
&__permissions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
&__permissions-count {
font-size: 14px;
color: #a0a0a0;
}
&__delete-all-btn {
padding: 6px 12px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__permissions-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
&__permission-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
&__permission-origin {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: #e8e8e8;
word-break: break-all;
}
&__permission-delete {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 12px;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Confirmation dialog
&__confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
&__confirm-dialog {
background: linear-gradient(135deg, #1e293b 0%, #1a1a2e 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
animation: slideUp 0.2s ease;
h3 {
font-size: 18px;
font-weight: 600;
color: #fff;
margin-bottom: 12px;
}
p {
font-size: 14px;
color: #e8e8e8;
margin-bottom: 8px;
}
}
&__confirm-warning {
font-size: 13px !important;
color: #a0a0a0 !important;
margin-top: 12px !important;
}
&__confirm-actions {
display: flex;
gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
&__confirm-cancel {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #e8e8e8;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
&__confirm-delete {
padding: 10px 20px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.6);
}
}
&__footer {
margin-top: 24px;
text-align: center;
@@ -202,3 +450,23 @@ body {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import browser from 'webextension-polyfill';
import { LogLevel, logLevelToName, logger } from '@tlsn/common';
import {
getStoredLogLevel,
@@ -7,6 +8,11 @@ import {
} from '../../utils/logLevelStorage';
import './index.scss';
// Initialize logger
logger.init(LogLevel.DEBUG);
type TabId = 'logging' | 'permissions';
interface LogLevelOption {
level: LogLevel;
name: string;
@@ -37,19 +43,25 @@ const LOG_LEVEL_OPTIONS: LogLevelOption[] = [
];
const Options: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('logging');
const [currentLevel, setCurrentLevel] = useState<LogLevel>(LogLevel.WARN);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
// Permissions state
const [hostPermissions, setHostPermissions] = useState<string[]>([]);
const [permissionsLoading, setPermissionsLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
// Load current log level on mount
useEffect(() => {
const loadLevel = async () => {
try {
const level = await getStoredLogLevel();
setCurrentLevel(level);
// Initialize the logger with the stored level
logger.init(level);
logger.setLevel(level);
} catch (error) {
logger.error('Failed to load log level:', error);
} finally {
@@ -60,6 +72,65 @@ const Options: React.FC = () => {
loadLevel();
}, []);
// Load permissions when permissions tab is active
useEffect(() => {
if (activeTab === 'permissions') {
loadPermissions();
}
}, [activeTab]);
// Manifest-defined patterns that cannot be removed via permissions API
// Note: Since we removed declarative content_scripts from manifest.json,
// we no longer have required host permissions. Only web_accessible_resources
// uses <all_urls>, but that doesn't create host permissions.
// We still filter out wildcard patterns as a safety measure.
const MANIFEST_PATTERNS = new Set([
'http://*/*',
'https://*/*',
'<all_urls>',
]);
/**
* Check if an origin is removable (runtime-granted optional permission)
* A permission is removable if:
* 1. It's not a manifest-defined pattern (http://*\/* or https://*\/*)
* 2. It's a specific host pattern (e.g., https://api.x.com/*)
*/
const isRemovableOrigin = (origin: string): boolean => {
// Not removable if it's a manifest pattern
if (MANIFEST_PATTERNS.has(origin)) {
return false;
}
// Only consider specific host patterns as removable
// These are patterns like "https://api.x.com/*" (not wildcards)
try {
const url = new URL(origin.replace('/*', '/'));
// If host contains wildcards, it's not a specific host
if (url.hostname.includes('*')) {
return false;
}
return true;
} catch {
return false;
}
};
const loadPermissions = async () => {
setPermissionsLoading(true);
try {
const permissions = await browser.permissions.getAll();
logger.debug('All permissions:', permissions.origins);
// Filter to only show removable host permissions
const origins = (permissions.origins || []).filter(isRemovableOrigin);
logger.debug('Removable permissions:', origins);
setHostPermissions(origins);
} catch (error) {
logger.error('Failed to load permissions:', error);
} finally {
setPermissionsLoading(false);
}
};
const handleLevelChange = useCallback(async (level: LogLevel) => {
setSaving(true);
setSaveSuccess(false);
@@ -67,11 +138,9 @@ const Options: React.FC = () => {
try {
await setStoredLogLevel(level);
setCurrentLevel(level);
// Update the logger immediately
logger.setLevel(level);
setSaveSuccess(true);
// Clear success message after 2 seconds
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save log level:', error);
@@ -80,6 +149,121 @@ const Options: React.FC = () => {
}
}, []);
const handleDeletePermission = useCallback(async (origin: string) => {
logger.info('[Options] handleDeletePermission called for:', origin);
logger.info(
'[Options] isRemovableOrigin check:',
isRemovableOrigin(origin),
);
// Double-check that this origin is actually removable
if (!isRemovableOrigin(origin)) {
logger.warn('[Options] Origin is not removable, skipping:', origin);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
setConfirmDelete(null);
return;
}
setDeleting(origin);
try {
// Get current permissions to verify the origin exists
const currentPerms = await browser.permissions.getAll();
logger.info('[Options] Current permissions:', currentPerms.origins);
if (!currentPerms.origins?.includes(origin)) {
logger.info(
'[Options] Origin not in current permissions, removing from UI',
);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
return;
}
logger.info('[Options] Calling browser.permissions.remove for:', origin);
const removed = await browser.permissions.remove({ origins: [origin] });
if (removed) {
setHostPermissions((prev) => prev.filter((o) => o !== origin));
logger.info('[Options] Permission removed:', origin);
} else {
logger.warn('[Options] Failed to remove permission:', origin);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error('[Options] Error removing permission:', errorMessage);
// If Chrome says it's a required permission, remove from UI anyway
if (errorMessage.includes('required permissions')) {
logger.warn(
'[Options] Chrome considers this a required permission, removing from UI:',
origin,
);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
}
} finally {
setDeleting(null);
setConfirmDelete(null);
}
}, []);
const handleDeleteAllPermissions = useCallback(async () => {
if (hostPermissions.length === 0) return;
logger.info(
'[Options] handleDeleteAllPermissions called for:',
hostPermissions,
);
setDeleting('all');
try {
// Filter to only actually removable permissions
const removableOrigins = hostPermissions.filter(isRemovableOrigin);
logger.info('[Options] Filtered removable origins:', removableOrigins);
if (removableOrigins.length === 0) {
logger.info('[Options] No removable permissions');
setHostPermissions([]);
return;
}
// Get current permissions to verify
const currentPerms = await browser.permissions.getAll();
const existingOrigins = new Set(currentPerms.origins || []);
const originsToRemove = removableOrigins.filter((o) =>
existingOrigins.has(o),
);
logger.info('[Options] Origins that actually exist:', originsToRemove);
if (originsToRemove.length === 0) {
logger.info('[Options] None of the origins exist in permissions');
setHostPermissions([]);
return;
}
const removed = await browser.permissions.remove({
origins: originsToRemove,
});
if (removed) {
setHostPermissions([]);
logger.info('[Options] All permissions removed');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error('[Options] Error removing all permissions:', errorMessage);
// If it's a "required permissions" error, just clear the UI
if (errorMessage.includes('required permissions')) {
logger.warn('[Options] Some permissions are required, clearing UI');
// Reload to show actual state
loadPermissions();
}
} finally {
setDeleting(null);
setConfirmDelete(null);
}
}, [hostPermissions]);
if (loading) {
return (
<div className="options options--loading">
@@ -95,54 +279,190 @@ const Options: React.FC = () => {
<h1>TLSN Extension Settings</h1>
</header>
{/* Tabs */}
<nav className="options__tabs">
<button
className={`options__tab ${activeTab === 'logging' ? 'options__tab--active' : ''}`}
onClick={() => setActiveTab('logging')}
>
Logging
</button>
<button
className={`options__tab ${activeTab === 'permissions' ? 'options__tab--active' : ''}`}
onClick={() => setActiveTab('permissions')}
>
Permissions
</button>
</nav>
<main className="options__content">
<section className="options__section">
<h2>Logging</h2>
<p className="options__section-description">
Control the verbosity of console logs. Lower levels include all
higher severity logs.
</p>
{/* Logging Tab */}
{activeTab === 'logging' && (
<section className="options__section">
<h2>Log Level</h2>
<p className="options__section-description">
Control the verbosity of console logs. Lower levels include all
higher severity logs.
</p>
<div className="options__log-levels">
{LOG_LEVEL_OPTIONS.map((option) => (
<label
key={option.level}
className={`options__radio-label ${
currentLevel === option.level
? 'options__radio-label--selected'
: ''
}`}
>
<input
type="radio"
name="logLevel"
value={option.level}
checked={currentLevel === option.level}
onChange={() => handleLevelChange(option.level)}
disabled={saving}
className="options__radio-input"
/>
<span className="options__radio-custom"></span>
<span className="options__radio-text">
<span className="options__radio-name">{option.name}</span>
<span className="options__radio-description">
{option.description}
<div className="options__log-levels">
{LOG_LEVEL_OPTIONS.map((option) => (
<label
key={option.level}
className={`options__radio-label ${
currentLevel === option.level
? 'options__radio-label--selected'
: ''
}`}
>
<input
type="radio"
name="logLevel"
value={option.level}
checked={currentLevel === option.level}
onChange={() => handleLevelChange(option.level)}
disabled={saving}
className="options__radio-input"
/>
<span className="options__radio-custom"></span>
<span className="options__radio-text">
<span className="options__radio-name">{option.name}</span>
<span className="options__radio-description">
{option.description}
</span>
</span>
</span>
</label>
))}
</div>
</label>
))}
</div>
<div className="options__status">
{saving && <span className="options__saving">Saving...</span>}
{saveSuccess && (
<span className="options__success">Settings saved!</span>
<div className="options__status">
{saving && <span className="options__saving">Saving...</span>}
{saveSuccess && (
<span className="options__success">Settings saved!</span>
)}
<span className="options__current">
Current: {logLevelToName(currentLevel)}
</span>
</div>
</section>
)}
{/* Permissions Tab */}
{activeTab === 'permissions' && (
<section className="options__section">
<h2>Host Permissions</h2>
<p className="options__section-description">
These are the hosts the extension currently has access to. You can
revoke access by clicking the trash icon.
</p>
{permissionsLoading ? (
<div className="options__permissions-loading">
<div className="options__spinner options__spinner--small"></div>
<span>Loading permissions...</span>
</div>
) : hostPermissions.length === 0 ? (
<div className="options__permissions-empty">
<span className="options__permissions-empty-icon">🔒</span>
<p>No host permissions granted</p>
<p className="options__permissions-empty-hint">
Permissions are requested when you run plugins that need
network access.
</p>
</div>
) : (
<>
<div className="options__permissions-header">
<span className="options__permissions-count">
{hostPermissions.length} host
{hostPermissions.length !== 1 ? 's' : ''}
</span>
{hostPermissions.length > 1 && (
<button
className="options__delete-all-btn"
onClick={() => setConfirmDelete('all')}
disabled={deleting !== null}
>
Remove All
</button>
)}
</div>
<ul className="options__permissions-list">
{hostPermissions.map((origin) => (
<li key={origin} className="options__permission-item">
<span className="options__permission-origin">
{origin}
</span>
<button
className="options__permission-delete"
onClick={() => setConfirmDelete(origin)}
disabled={deleting !== null}
title="Remove permission"
>
{deleting === origin ? (
<span className="options__spinner options__spinner--tiny"></span>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
</li>
))}
</ul>
</>
)}
<span className="options__current">
Current: {logLevelToName(currentLevel)}
</span>
</div>
</section>
{/* Confirmation Dialog */}
{confirmDelete && (
<div className="options__confirm-overlay">
<div className="options__confirm-dialog">
<h3>Confirm Removal</h3>
<p>
{confirmDelete === 'all'
? `Remove all ${hostPermissions.length} host permissions?`
: `Remove permission for "${confirmDelete}"?`}
</p>
<p className="options__confirm-warning">
Plugins will need to request this permission again to access
these hosts.
</p>
<div className="options__confirm-actions">
<button
className="options__confirm-cancel"
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
<button
className="options__confirm-delete"
onClick={() =>
confirmDelete === 'all'
? handleDeleteAllPermissions()
: handleDeletePermission(confirmDelete)
}
>
Remove
</button>
</div>
</div>
</div>
)}
</section>
)}
</main>
<footer className="options__footer">

View File

@@ -15,7 +15,7 @@
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
"matches": ["<all_urls>"],
"js": ["contentScript.bundle.js"],
"css": ["content.styles.css"]
}
@@ -23,10 +23,10 @@
"web_accessible_resources": [
{
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "*.wasm"],
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
"matches": ["<all_urls>"]
}
],
"host_permissions": ["<all_urls>"],
"optional_host_permissions": ["<all_urls>"],
"permissions": [
"offscreen",
"webRequest",

View File

@@ -117,6 +117,9 @@ export interface ManagedWindow {
/** Whether to show overlay when tab becomes ready (complete status) */
showOverlayWhenReady: boolean;
/** Origins granted for this window's plugin session (for cleanup on close) */
grantedOrigins: string[];
}
/**

View File

@@ -0,0 +1,317 @@
/**
* Tests for PermissionManager
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PermissionManager } from '../../src/background/PermissionManager';
import type { RequestPermission } from '@tlsn/plugin-sdk/src/types';
// Mock webextension-polyfill
vi.mock('webextension-polyfill', () => ({
default: {
permissions: {
request: vi.fn(),
remove: vi.fn(),
contains: vi.fn(),
},
},
}));
// Mock logger
vi.mock('@tlsn/common', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import browser from 'webextension-polyfill';
describe('PermissionManager', () => {
let manager: PermissionManager;
beforeEach(() => {
manager = new PermissionManager();
vi.clearAllMocks();
});
describe('extractPermissionPatterns', () => {
it('should extract origin pattern from host', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
];
const patterns = manager.extractPermissionPatterns(requests);
expect(patterns).toHaveLength(2);
expect(patterns[0]).toEqual({
origin: 'https://api.x.com/*',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
});
expect(patterns[1]).toEqual({
origin: 'https://verifier.example.com/*',
host: 'verifier.example.com',
pathname: '/*',
});
});
it('should deduplicate origins', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
{
method: 'POST',
host: 'api.x.com',
pathname: '/1.1/statuses/update.json',
verifierUrl: 'https://verifier.example.com',
},
];
const patterns = manager.extractPermissionPatterns(requests);
// Should only have 2 unique origins (api.x.com + verifier.example.com)
expect(patterns).toHaveLength(2);
});
it('should handle http verifier URL', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'http://localhost:7047',
},
];
const patterns = manager.extractPermissionPatterns(requests);
expect(patterns).toHaveLength(2);
expect(patterns[1].origin).toBe('http://localhost:7047/*');
});
it('should handle invalid verifier URL gracefully', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'not-a-valid-url',
},
];
const patterns = manager.extractPermissionPatterns(requests);
// Should only have the host origin, skip invalid verifier
expect(patterns).toHaveLength(1);
expect(patterns[0].origin).toBe('https://api.x.com/*');
});
});
describe('extractOrigins', () => {
it('should return just origin strings', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'https://verifier.example.com',
},
];
const origins = manager.extractOrigins(requests);
expect(origins).toEqual([
'https://api.x.com/*',
'https://verifier.example.com/*',
]);
});
});
describe('formatForDisplay', () => {
it('should combine host and pathname', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
{
method: 'POST',
host: 'api.twitter.com',
pathname: '/graphql/*',
verifierUrl: 'https://verifier.example.com',
},
];
const display = manager.formatForDisplay(requests);
expect(display).toEqual([
'api.x.com/1.1/users/show.json',
'api.twitter.com/graphql/*',
]);
});
});
describe('requestPermissions', () => {
it('should return true for empty origins', async () => {
const granted = await manager.requestPermissions([]);
expect(granted).toBe(true);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
it('should request permissions and return result', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(true);
expect(browser.permissions.request).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
});
it('should return true if permissions already granted', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(true);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(true);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
it('should return false on denial', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(false);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(false);
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockRejectedValue(
new Error('Permission error'),
);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(false);
});
it('should track permission usage', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
});
});
describe('removePermissions', () => {
it('should return true for empty origins', async () => {
const removed = await manager.removePermissions([]);
expect(removed).toBe(true);
expect(browser.permissions.remove).not.toHaveBeenCalled();
});
it('should remove permissions when no longer in use', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
vi.mocked(browser.permissions.remove).mockResolvedValue(true);
// First request permissions
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
// Then remove
const removed = await manager.removePermissions(['https://api.x.com/*']);
expect(removed).toBe(true);
expect(browser.permissions.remove).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(0);
});
it('should not remove permissions still in use by other executions', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
vi.mocked(browser.permissions.remove).mockResolvedValue(true);
// Request permissions twice (simulating two concurrent executions)
await manager.requestPermissions(['https://api.x.com/*']);
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(2);
// Remove once
await manager.removePermissions(['https://api.x.com/*']);
// Should NOT call browser.permissions.remove because still in use
expect(browser.permissions.remove).not.toHaveBeenCalled();
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
// Remove second time
await manager.removePermissions(['https://api.x.com/*']);
// Now it should call browser.permissions.remove
expect(browser.permissions.remove).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(0);
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.remove).mockRejectedValue(
new Error('Remove error'),
);
const removed = await manager.removePermissions(['https://api.x.com/*']);
expect(removed).toBe(false);
});
});
describe('hasPermissions', () => {
it('should return true for empty origins', async () => {
const has = await manager.hasPermissions([]);
expect(has).toBe(true);
});
it('should check permissions with browser API', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(true);
const has = await manager.hasPermissions(['https://api.x.com/*']);
expect(has).toBe(true);
expect(browser.permissions.contains).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.contains).mockRejectedValue(
new Error('Check error'),
);
const has = await manager.hasPermissions(['https://api.x.com/*']);
expect(has).toBe(false);
});
});
});