mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 07:29:00 +02:00
fix(extension): deny token/port reads to content-script and foreign senders
background.js answered getPort — port, connected state, AND the browse
server auth token — to any sender that passed the type allowlist,
including content scripts running in web-page context and, behind only
the sender.id check, anything without extension-page provenance. The
getToken sender.tab restriction covered getToken alone, and only after
getPort had already handed out the token.
Single decision point now: extension/sender-auth.js classifies each
message type; the eight privileged types (getPort, setPort, getServerUrl,
getToken, fetchRefs, command, sidebar-command, getTabState) require an
own-extension-page sender (chrome-extension://<own id>/ URL, no
sender.tab, own sender.id). Denied senders get { error: 'unauthorized' }
and nothing else — never the token, never the port. Content-script flows
(elementPicked, pickerCancelled, inspectResult, openSidePanel) are
untouched, and the sidepanel/popup keep the getPort token field their
connect path reads. The policy mirrors the v1.63 server-side model:
AUTH_TOKEN is released only to the pinned extension Origin via
POST /extension-token, so the extension must not re-leak it to contexts
the server would never have trusted.
browse/test/extension-sender-auth.test.ts drives the real background.js
onMessage listener under a chrome stub with four sender shapes (own
extension page, own content script, foreign extension id, missing
sender.url) and pins that denied responses carry no token/port fields,
that a denied setPort never persists, that a denied command never
reaches the network, and that the inspector + tab-state flows keep
working. The helper is loaded via importScripts in the classic service
worker and require()-able from bun tests.
Contributed by @punksterlabs (PR #1822; reimplemented against the v1.63 POST /extension-token pinned-origin model).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2aec845c1e
commit
b3d477bbb5
+23
-8
@@ -5,8 +5,14 @@
|
||||
* Fetches /refs on snapshot completion, relays to content script.
|
||||
* Proxies commands from sidebar → browse server.
|
||||
* Updates badge: amber (connected), gray (disconnected).
|
||||
* Denies token/port reads to content-script and foreign senders.
|
||||
*/
|
||||
|
||||
// Sender authorization for privileged message types (the token/port surface).
|
||||
// Classic (non-module) service worker: importScripts puts gstackSenderAuth on
|
||||
// the worker global. The same file is require()-able from bun tests.
|
||||
importScripts('sender-auth.js');
|
||||
|
||||
const DEFAULT_PORT = 34567; // Well-known port used by `$B connect`
|
||||
let serverPort = null;
|
||||
let authToken = null;
|
||||
@@ -309,6 +315,19 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Privileged types — anything that returns or spends the auth token or
|
||||
// server port, or dumps the full tab list — are for this extension's own
|
||||
// pages only (sidepanel/popup). Content scripts run inside web pages and
|
||||
// can be influenced by page content; foreign extensions are foreign. Both
|
||||
// get { error: 'unauthorized' } and nothing else — never the token, never
|
||||
// the port. Policy + type list live in sender-auth.js.
|
||||
const denial = gstackSenderAuth.denialFor(msg.type, sender, chrome.runtime.id);
|
||||
if (denial) {
|
||||
console.warn('[gstack] Rejected privileged message from unauthorized sender:', msg.type, sender.url || '(no sender url)');
|
||||
sendResponse(denial);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'getPort') {
|
||||
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
|
||||
return true;
|
||||
@@ -333,15 +352,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
// Token delivered via targeted sendResponse, not broadcast — limits exposure.
|
||||
// Only respond to extension pages (sidepanel/popup) — content scripts have
|
||||
// sender.tab set, so reject those to prevent token access from injected contexts.
|
||||
// Only this extension's own pages reach here: the sender-auth gate above
|
||||
// denies content scripts (sender.tab set) and foreign senders before any
|
||||
// privileged handler runs.
|
||||
if (msg.type === 'getToken') {
|
||||
if (sender.tab) {
|
||||
console.warn('[gstack] Rejected getToken from content script context');
|
||||
sendResponse({ token: null });
|
||||
} else {
|
||||
sendResponse({ token: authToken });
|
||||
}
|
||||
sendResponse({ token: authToken });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* gstack browse — sender authorization for privileged extension messages
|
||||
*
|
||||
* Single decision point for which chrome.runtime.onMessage senders may read
|
||||
* or spend the browse server's auth token and port. Loaded into the
|
||||
* background service worker via importScripts() (classic worker — see
|
||||
* manifest.json) and require()-able from bun tests
|
||||
* (browse/test/extension-sender-auth.test.ts).
|
||||
*
|
||||
* Policy: privileged types are for this extension's own pages only
|
||||
* (sidepanel / popup — sender.url is chrome-extension://<own id>/...).
|
||||
* Content scripts run in web-page context (sender.tab is set, sender.url is
|
||||
* the page URL) and can be influenced by page content; foreign extensions
|
||||
* have a different sender.id. Both are denied, and a denied sender gets
|
||||
* { error: 'unauthorized' } with no other fields — never the token, never
|
||||
* the port. This mirrors the server side of the v1.63 token model: the
|
||||
* browse server releases AUTH_TOKEN only to the pinned extension Origin via
|
||||
* POST /extension-token, so the extension must not re-leak it to contexts
|
||||
* the server would never have trusted.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// Message types that return or spend the auth token / server port, or leak
|
||||
// privileged browser state. Every other type in background.js's allowlist
|
||||
// stays reachable from content scripts — the inspector flow (elementPicked,
|
||||
// pickerCancelled, inspectResult) and openSidePanel are content-script-
|
||||
// originated by design.
|
||||
const PRIVILEGED_TYPES = new Set([
|
||||
'getPort', // response carries port + connected state + token
|
||||
'setPort', // repoints the token-bearing client at another port
|
||||
'getServerUrl', // response carries the server URL (port)
|
||||
'getToken', // response carries the token
|
||||
'fetchRefs', // spends the token on an authorized /refs fetch
|
||||
'command', // spends the token on an arbitrary browse command
|
||||
'sidebar-command', // spends the token on a server POST
|
||||
'getTabState', // response carries every open tab's URL + title
|
||||
]);
|
||||
|
||||
// Extension-page senders only: popup / sidepanel / options. A content
|
||||
// script has sender.tab set and a web-page sender.url; a foreign extension
|
||||
// has a different sender.id; a sender with no URL has no provenance at all.
|
||||
// All three are denied.
|
||||
function isExtensionPageSender(sender, ownExtensionId) {
|
||||
if (!sender || !ownExtensionId) return false;
|
||||
if (sender.id !== ownExtensionId) return false;
|
||||
if (sender.tab) return false;
|
||||
if (typeof sender.url !== 'string') return false;
|
||||
return sender.url.startsWith('chrome-extension://' + ownExtensionId + '/');
|
||||
}
|
||||
|
||||
// Returns null when the message may proceed, or the exact response a
|
||||
// denied sender receives: { error: 'unauthorized' } and nothing else.
|
||||
function denialFor(msgType, sender, ownExtensionId) {
|
||||
if (!PRIVILEGED_TYPES.has(msgType)) return null;
|
||||
if (isExtensionPageSender(sender, ownExtensionId)) return null;
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
const api = { PRIVILEGED_TYPES, isExtensionPageSender, denialFor };
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api; // bun test (CommonJS require)
|
||||
}
|
||||
root.gstackSenderAuth = api; // importScripts() in the service worker
|
||||
})(typeof self !== 'undefined' ? self : globalThis);
|
||||
Reference in New Issue
Block a user