From 0936a6897de46dcf21b36f88a381c59f1ffb27f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 17 Dec 2025 17:48:34 +0100 Subject: [PATCH] [Devtools] Narrow host permissions to specific domains The Chrome Web Store discourages extensions that request access to all URLs, as it triggers additional review scrutiny and can delay publishing. This change limits the default host_permissions to known Playground domains (playground.wordpress.net, developer.wordpress.org, localhost, etc.) while adding optional_host_permissions for everything else. When users visit a site not in the allowlist, the DevTools panel shows a permission request UI. Once granted, the content script is injected programmatically and scanning resumes. This gives users full flexibility while keeping the default permission scope minimal. --- .../devtools-extension/public/manifest.json | 21 +++- .../devtools-extension/src/background.ts | 32 ++++++ .../src/panel/panel.module.css | 22 ++++ .../devtools-extension/src/panel/panel.tsx | 101 +++++++++++++++++- .../devtools-extension/src/permissions.ts | 73 +++++++++++++ 5 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 packages/playground/devtools-extension/src/permissions.ts diff --git a/packages/playground/devtools-extension/public/manifest.json b/packages/playground/devtools-extension/public/manifest.json index db60e3f80a..197ff8b884 100644 --- a/packages/playground/devtools-extension/public/manifest.json +++ b/packages/playground/devtools-extension/public/manifest.json @@ -10,14 +10,31 @@ }, "devtools_page": "devtools/index.html", "permissions": ["scripting", "activeTab", "webNavigation"], - "host_permissions": [""], + "host_permissions": [ + "*://playground.wordpress.net/*", + "*://developer.wordpress.org/*", + "*://developer.woocommerce.com/*", + "*://developer.wordpress.com/*", + "*://wordpress.org/*", + "*://localhost/*", + "*://127.0.0.1/*" + ], + "optional_host_permissions": [""], "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [ { - "matches": [""], + "matches": [ + "*://playground.wordpress.net/*", + "*://developer.wordpress.org/*", + "*://developer.woocommerce.com/*", + "*://developer.wordpress.com/*", + "*://wordpress.org/*", + "*://localhost/*", + "*://127.0.0.1/*" + ], "js": ["content-script.js"], "run_at": "document_idle", "all_frames": true diff --git a/packages/playground/devtools-extension/src/background.ts b/packages/playground/devtools-extension/src/background.ts index 2dbed553ce..44978ce58d 100644 --- a/packages/playground/devtools-extension/src/background.ts +++ b/packages/playground/devtools-extension/src/background.ts @@ -19,6 +19,30 @@ const playgroundFrames = new Map>(); // Store connections to DevTools panels const devToolsConnections = new Map(); +/** + * Inject content script into a tab/frame programmatically. + * Used when permission is granted for a non-allowlisted domain. + */ +async function injectContentScript(tabId: number, frameIds?: number[]) { + try { + const target: chrome.scripting.InjectionTarget = { tabId }; + if (frameIds && frameIds.length > 0) { + target.frameIds = frameIds; + } else { + target.allFrames = true; + } + + await chrome.scripting.executeScript({ + target, + files: ['content-script.js'], + }); + } catch (error) { + // Ignore errors - may fail if page doesn't allow script injection + // eslint-disable-next-line no-console + console.debug('Failed to inject content script:', error); + } +} + /** * Handle messages from content scripts. */ @@ -260,6 +284,14 @@ chrome.runtime.onConnect.addListener((port) => { }); }); } + + // Handle request to inject content script after permission is granted + if (message.type === 'INJECT_CONTENT_SCRIPT' && tabId !== null) { + injectContentScript(tabId).then(() => { + // After injection, trigger a refresh to detect playgrounds + port.postMessage({ type: 'INJECTION_COMPLETE' }); + }); + } }); port.onDisconnect.addListener(() => { diff --git a/packages/playground/devtools-extension/src/panel/panel.module.css b/packages/playground/devtools-extension/src/panel/panel.module.css index cf779ffea4..1e646a0b4c 100644 --- a/packages/playground/devtools-extension/src/panel/panel.module.css +++ b/packages/playground/devtools-extension/src/panel/panel.module.css @@ -103,3 +103,25 @@ opacity: 1; } } + +.permissionButton { + margin-top: 16px; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: #ffffff; + background-color: var(--wp-admin-theme-color, #3858e9); + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.permissionButton:hover:not(:disabled) { + background-color: var(--wp-admin-theme-color-darker-10, #2145e6); +} + +.permissionButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/packages/playground/devtools-extension/src/panel/panel.tsx b/packages/playground/devtools-extension/src/panel/panel.tsx index 94eff1157b..b714ac4dd9 100644 --- a/packages/playground/devtools-extension/src/panel/panel.tsx +++ b/packages/playground/devtools-extension/src/panel/panel.tsx @@ -1,7 +1,12 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { createRoot } from 'react-dom/client'; import { PlaygroundFileEditor } from '@wp-playground/components'; import type { AsyncWritableFilesystem } from '@wp-playground/storage'; +import { + hasPermissionForUrl, + requestPermissionForUrl, + isAllowlistedUrl, +} from '../permissions'; import styles from './panel.module.css'; interface PlaygroundFrame { @@ -119,11 +124,70 @@ function PlaygroundPanel() { const [filesystem, setFilesystem] = useState(null); const [isConnected, setIsConnected] = useState(false); + const [tabUrl, setTabUrl] = useState(null); + const [hasPermission, setHasPermission] = useState(null); + const [isRequestingPermission, setIsRequestingPermission] = useState(false); const portRef = useRef(null); const refreshIntervalRef = useRef(null); + // Check permission for the current tab + const checkPermission = useCallback(async () => { + try { + // Get the inspected tab's URL via eval since we can't directly query it + chrome.devtools.inspectedWindow.eval( + 'window.location.href', + (result: unknown, error) => { + if (error || typeof result !== 'string') { + // Fallback: assume we need permission + setTabUrl(null); + setHasPermission(false); + return; + } + const url = result; + setTabUrl(url); + + // If URL is in allowlist, we have permission by default + if (isAllowlistedUrl(url)) { + setHasPermission(true); + return; + } + + // Check if we have permission for this URL + hasPermissionForUrl(url).then(setHasPermission); + } + ); + } catch { + setHasPermission(false); + } + }, []); + + // Request permission for the current tab + const handleRequestPermission = useCallback(async () => { + if (!tabUrl) return; + + setIsRequestingPermission(true); + try { + const granted = await requestPermissionForUrl(tabUrl); + setHasPermission(granted); + + if (granted && portRef.current) { + // Inject content script now that we have permission + portRef.current.postMessage({ type: 'INJECT_CONTENT_SCRIPT' }); + // Trigger a refresh after injection + setTimeout(() => { + portRef.current?.postMessage({ type: 'REFRESH_FRAMES' }); + }, 500); + } + } finally { + setIsRequestingPermission(false); + } + }, [tabUrl]); + // Connect to the background script and set up frame detection useEffect(() => { + // Check permission first + checkPermission(); + const port = chrome.runtime.connect({ name: 'playground-devtools' }); portRef.current = port; @@ -142,6 +206,10 @@ function PlaygroundPanel() { setSelectedFrame(message.frames[0]); } } + if (message.type === 'INJECTION_COMPLETE') { + // Re-check for frames after content script injection + port.postMessage({ type: 'REFRESH_FRAMES' }); + } }); // Request initial frame refresh @@ -165,7 +233,7 @@ function PlaygroundPanel() { } port.disconnect(); }; - }, []); + }, [checkPermission]); // Create filesystem when a frame is selected useEffect(() => { @@ -203,6 +271,35 @@ function PlaygroundPanel() { ); } + // If we don't have permission for this domain + if (hasPermission === false && !isAllowlistedUrl(tabUrl || '')) { + const hostname = tabUrl ? new URL(tabUrl).hostname : 'this site'; + return ( +
+
+

Permission Required

+

+ To inspect WordPress Playground on{' '} + {hostname}, this extension needs + permission to access the page. +

+ +

+ This permission only applies to {hostname} +

+
+
+ ); + } + // If no playground frames found if (frames.length === 0) { return ( diff --git a/packages/playground/devtools-extension/src/permissions.ts b/packages/playground/devtools-extension/src/permissions.ts new file mode 100644 index 0000000000..f6941e49e5 --- /dev/null +++ b/packages/playground/devtools-extension/src/permissions.ts @@ -0,0 +1,73 @@ +/** + * Permission utilities for the WordPress Playground DevTools extension. + * + * Handles checking and requesting host permissions for domains that aren't + * in the default allowlist. + */ + +/** + * Check if we have permission to access a given URL. + */ +export async function hasPermissionForUrl(url: string): Promise { + try { + return await chrome.permissions.contains({ + origins: [getOriginPattern(url)], + }); + } catch { + return false; + } +} + +/** + * Request permission to access a given URL. + * Returns true if permission was granted, false otherwise. + */ +export async function requestPermissionForUrl(url: string): Promise { + try { + return await chrome.permissions.request({ + origins: [getOriginPattern(url)], + }); + } catch { + return false; + } +} + +/** + * Convert a URL to an origin pattern suitable for the permissions API. + * e.g., "https://example.com/page" -> "https://example.com/*" + */ +function getOriginPattern(url: string): string { + try { + const urlObj = new URL(url); + return `${urlObj.protocol}//${urlObj.host}/*`; + } catch { + // If URL parsing fails, return a pattern that won't match anything + return url; + } +} + +/** + * Check if a URL matches the default allowlist in manifest.json. + */ +export function isAllowlistedUrl(url: string): boolean { + const allowlist = [ + 'playground.wordpress.net', + 'developer.wordpress.org', + 'developer.woocommerce.com', + 'developer.wordpress.com', + 'wordpress.org', + 'localhost', + '127.0.0.1', + ]; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + return allowlist.some( + (allowed) => + hostname === allowed || hostname.endsWith('.' + allowed) + ); + } catch { + return false; + } +}