chrome-bridge-mcp 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/extension/console-capture.js +46 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +44 -0
- package/extension/page-instrumentation.js +115 -0
- package/extension/popup.css +128 -0
- package/extension/popup.html +64 -0
- package/extension/popup.js +142 -0
- package/extension/service-worker.js +3711 -0
- package/extension/stack-detect.js +54 -0
- package/extension/telemetry.js +29 -0
- package/install.sh +70 -0
- package/package.json +57 -0
- package/server/assertions.js +81 -0
- package/server/cli.js +380 -0
- package/server/formatters.js +35 -0
- package/server/har.js +22 -0
- package/server/index.js +107 -0
- package/server/launcher.js +97 -0
- package/server/link-checker.js +57 -0
- package/server/protocol.js +135 -0
- package/server/security-headers.js +68 -0
- package/server/stub-server.js +81 -0
- package/server/tools.js +1728 -0
- package/server/ws-manager.js +655 -0
- package/tools/measure-schema.mjs +108 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatter riga-per-riga condivisi tra tool MCP (tools.js) e CLI (cli.js).
|
|
3
|
+
* Un'unica fonte: stesso output sui due canali, niente divergenza.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Messaggi console: "level<TAB>+Δms<TAB>testo", delta relativo al primo mostrato. */
|
|
7
|
+
export function consoleLines(tail, total) {
|
|
8
|
+
const t0 = tail[0]?.timestamp ?? 0;
|
|
9
|
+
const lines = tail.map((m) => `${m.level}\t+${(m.timestamp ?? t0) - t0}ms\t${(m.args ?? []).join(' ')}`);
|
|
10
|
+
return `console total=${total} shown=${tail.length}\n${lines.join('\n')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Richieste di rete: "status<TAB>ms<TAB>method<TAB>url", errori come ERR(msg). */
|
|
14
|
+
export function networkLines(tail, total) {
|
|
15
|
+
const lines = tail.map((r) => `${r.status ?? `ERR(${r.error})`}\t${r.duration}ms\t${r.method}\t${r.url}`);
|
|
16
|
+
return `network total=${total} shown=${tail.length}\n${lines.join('\n')}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Elementi interattivi: flag (disabled/hidden/occluded) solo quando anomali. Ref n1..nN in testa se presente. */
|
|
20
|
+
export function interactivesLines(data) {
|
|
21
|
+
const els = data?.elements ?? [];
|
|
22
|
+
const lines = els.map((e) => {
|
|
23
|
+
const flags = [!e.enabled && 'disabled', !e.visible && 'hidden', e.occluded && 'occluded'].filter(Boolean).join(',');
|
|
24
|
+
const rect = e.rect ? `@${e.rect.x},${e.rect.y} ${e.rect.width}x${e.rect.height}` : '';
|
|
25
|
+
return [...(e.ref ? [e.ref] : []), `${e.selector}`, `${e.tag}${e.type ? `:${e.type}` : ''}`, e.text ?? '', ...(flags ? [flags] : []), rect].join('\t');
|
|
26
|
+
});
|
|
27
|
+
const note = data?.note ? ` note=${data.note}` : '';
|
|
28
|
+
return `interactives count=${data?.count ?? els.length}${note}\n${lines.join('\n')}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Esito verifica link: "status<TAB>url<TAB>error". */
|
|
32
|
+
export function linksLines(results, { total, broken, anchors }) {
|
|
33
|
+
const lines = results.map((r) => `${r.status}\t${r.url}${r.error ? `\t${r.error}` : ''}`);
|
|
34
|
+
return `links total=${total} checked=${results.length} broken=${broken} anchors=${anchors}\n${lines.join('\n')}`;
|
|
35
|
+
}
|
package/server/har.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversione delle richieste catturate in formato HAR 1.2.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { VERSION } from './protocol.js';
|
|
6
|
+
|
|
7
|
+
export function toHar(requests) {
|
|
8
|
+
return {
|
|
9
|
+
log: {
|
|
10
|
+
version: '1.2',
|
|
11
|
+
creator: { name: 'chrome-bridge', version: VERSION },
|
|
12
|
+
entries: requests.map((r) => ({
|
|
13
|
+
startedDateTime: new Date(r.startTime).toISOString(),
|
|
14
|
+
time: r.duration ?? 0,
|
|
15
|
+
request: { method: r.method || 'GET', url: r.url, httpVersion: '', headers: [], queryString: [], cookies: [], headersSize: -1, bodySize: -1 },
|
|
16
|
+
response: { status: r.status ?? 0, statusText: r.error || '', httpVersion: '', headers: [], cookies: [], content: { size: -1, mimeType: '' }, redirectURL: '', headersSize: -1, bodySize: -1 },
|
|
17
|
+
cache: {},
|
|
18
|
+
timings: { send: 0, wait: r.duration ?? 0, receive: 0 },
|
|
19
|
+
})),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chrome Bridge MCP Server
|
|
5
|
+
*
|
|
6
|
+
* Entry point che avvia:
|
|
7
|
+
* 1. Il server WebSocket (per comunicare con l'estensione Chrome)
|
|
8
|
+
* 2. Il server MCP (per comunicare con Claude Code via stdio)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
|
+
import { WSManager } from './ws-manager.js';
|
|
14
|
+
import { registerTools } from './tools.js';
|
|
15
|
+
import { launchBrowser } from './launcher.js';
|
|
16
|
+
import { DEFAULT_PORT, VERSION } from './protocol.js';
|
|
17
|
+
|
|
18
|
+
// Launch mode: browser dedicato (profilo effimero + estensione unpacked).
|
|
19
|
+
// Porta effimera di default: zero conflitti con un bridge già attivo.
|
|
20
|
+
const LAUNCH = process.argv.includes('--launch');
|
|
21
|
+
const HEADLESS = process.argv.includes('--headless');
|
|
22
|
+
function argValue(flag) {
|
|
23
|
+
const i = process.argv.indexOf(flag);
|
|
24
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
25
|
+
}
|
|
26
|
+
// Bind: loopback di default, 0.0.0.0 solo su richiesta esplicita (Crostini).
|
|
27
|
+
const HOST = argValue('--host') ?? process.env.CHROME_BRIDGE_HOST ?? '127.0.0.1';
|
|
28
|
+
const PORT = process.env.CHROME_BRIDGE_PORT
|
|
29
|
+
? parseInt(process.env.CHROME_BRIDGE_PORT, 10)
|
|
30
|
+
: (LAUNCH ? 0 : DEFAULT_PORT);
|
|
31
|
+
|
|
32
|
+
// Capability: default = solo set core (30 tool). --caps audits,visual o
|
|
33
|
+
// CHROME_BRIDGE_CAPS attivano i gruppi opt-in; "all" registra tutto.
|
|
34
|
+
function parseCaps() {
|
|
35
|
+
const i = process.argv.indexOf('--caps');
|
|
36
|
+
if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1];
|
|
37
|
+
return process.env.CHROME_BRIDGE_CAPS || 'core';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
// 1. Crea il server MCP
|
|
42
|
+
// instructions: dette una volta qui invece che ripetute in ogni descrizione
|
|
43
|
+
// tool — pesano ~1 volta nel contesto del client anziché ~50.
|
|
44
|
+
const mcpServer = new McpServer({
|
|
45
|
+
name: 'chrome-bridge',
|
|
46
|
+
version: VERSION,
|
|
47
|
+
}, {
|
|
48
|
+
instructions: [
|
|
49
|
+
'Selector parameters on DOM tools support shadow-DOM piercing with ">>>" (e.g. "my-app >>> button.save").',
|
|
50
|
+
'tab_id omitted = the tab last navigated/created in this session, else the active tab. frame_id omitted = main frame (list frames with get_frames).',
|
|
51
|
+
'Prefer get_interactives over read_page(html) to discover targets; its refs (n1, n2…) work as the ref param of click/type_text/hover.',
|
|
52
|
+
// Il costo dominante sono i TURNI, non i byte: un turno vale ~15-30 volte
|
|
53
|
+
// un KB di output risparmiato. Queste due clausole si pagano una volta
|
|
54
|
+
// qui e valgono più di qualunque ottimizzazione di schema.
|
|
55
|
+
'For more than one field use fill_form once (with submit_selector to submit in the same call) instead of repeated type_text: one turn instead of N.',
|
|
56
|
+
'For tables use extract_table (server-side where/columns filtering) or extract, never read_page: read_page on a big table costs tens of thousands of tokens for data you filter anyway.',
|
|
57
|
+
].join(' '),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// 2. Avvia il WebSocket server
|
|
61
|
+
const wsManager = new WSManager(PORT, { host: HOST });
|
|
62
|
+
await wsManager.start();
|
|
63
|
+
|
|
64
|
+
// 2b. Launch mode: browser dedicato che si connette alla nostra porta.
|
|
65
|
+
// Gli handler di shutdown sono registrati PRIMA del launch: registrarli dopo
|
|
66
|
+
// lasciava Chromium e il profilo temporaneo orfani a ogni segnale ricevuto
|
|
67
|
+
// durante l'avvio (osservate 3 directory residue, una da 122 MB).
|
|
68
|
+
let browser = null;
|
|
69
|
+
const shutdown = async () => {
|
|
70
|
+
console.error('[chrome-bridge] Shutting down...');
|
|
71
|
+
try { if (browser) await browser.stop(); } catch {}
|
|
72
|
+
try { await wsManager.stop(); } catch {}
|
|
73
|
+
try { await mcpServer.close(); } catch {}
|
|
74
|
+
process.exit(0);
|
|
75
|
+
};
|
|
76
|
+
process.on('SIGINT', shutdown);
|
|
77
|
+
process.on('SIGTERM', shutdown);
|
|
78
|
+
process.on('uncaughtException', (err) => {
|
|
79
|
+
console.error('[chrome-bridge] Uncaught exception:', err);
|
|
80
|
+
shutdown().catch(() => process.exit(1));
|
|
81
|
+
});
|
|
82
|
+
process.on('unhandledRejection', (err) => {
|
|
83
|
+
console.error('[chrome-bridge] Unhandled rejection:', err);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (LAUNCH) {
|
|
87
|
+
if (wsManager.mode !== 'primary') {
|
|
88
|
+
throw new Error(`--launch requires a dedicated port, but ${wsManager.port} is owned by another chrome-bridge. Unset CHROME_BRIDGE_PORT (ephemeral) or pick a free one.`);
|
|
89
|
+
}
|
|
90
|
+
browser = await launchBrowser({ port: wsManager.port, headless: HEADLESS });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Registra i tool MCP (filtrati per capability)
|
|
94
|
+
registerTools(mcpServer, wsManager, parseCaps());
|
|
95
|
+
|
|
96
|
+
// 4. Avvia il trasporto stdio MCP
|
|
97
|
+
const transport = new StdioServerTransport();
|
|
98
|
+
await mcpServer.connect(transport);
|
|
99
|
+
|
|
100
|
+
console.error(`[chrome-bridge] MCP server ready (stdio + WebSocket, mode: ${wsManager.mode})`);
|
|
101
|
+
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch((err) => {
|
|
105
|
+
console.error('[chrome-bridge] Fatal error:', err);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch mode: avvia un browser Chromium dedicato (headless o no) con
|
|
3
|
+
* profilo effimero e l'estensione caricata unpacked da una copia temporanea.
|
|
4
|
+
*
|
|
5
|
+
* La copia contiene un launch.json con la porta WS del server: nel pacchetto
|
|
6
|
+
* Chrome Web Store il file non esiste e l'estensione usa la porta da storage.
|
|
7
|
+
* Profilo e copia vengono rimossi allo stop.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const EXTENSION_SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'extension');
|
|
18
|
+
|
|
19
|
+
const BROWSER_CANDIDATES = [
|
|
20
|
+
'/usr/bin/chromium',
|
|
21
|
+
'/usr/bin/chromium-browser',
|
|
22
|
+
'/usr/bin/google-chrome',
|
|
23
|
+
'/usr/bin/google-chrome-stable',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function findBrowser() {
|
|
27
|
+
if (process.env.CHROME_BRIDGE_BROWSER) {
|
|
28
|
+
if (!existsSync(process.env.CHROME_BRIDGE_BROWSER)) {
|
|
29
|
+
throw new Error(`CHROME_BRIDGE_BROWSER not found: ${process.env.CHROME_BRIDGE_BROWSER}`);
|
|
30
|
+
}
|
|
31
|
+
return process.env.CHROME_BRIDGE_BROWSER;
|
|
32
|
+
}
|
|
33
|
+
const found = BROWSER_CANDIDATES.find((p) => existsSync(p));
|
|
34
|
+
if (!found) {
|
|
35
|
+
throw new Error(`No Chromium/Chrome binary found (tried: ${BROWSER_CANDIDATES.join(', ')}). Set CHROME_BRIDGE_BROWSER.`);
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Prepara dir temporanee: copia estensione + launch.json, profilo con dev mode. */
|
|
41
|
+
export async function prepareLaunch(port) {
|
|
42
|
+
const base = await mkdtemp(join(tmpdir(), 'chrome-bridge-launch-'));
|
|
43
|
+
const extDir = join(base, 'ext');
|
|
44
|
+
const profileDir = join(base, 'profile');
|
|
45
|
+
|
|
46
|
+
await cp(EXTENSION_SRC, extDir, { recursive: true });
|
|
47
|
+
await writeFile(join(extDir, 'launch.json'), JSON.stringify({ port }));
|
|
48
|
+
|
|
49
|
+
// Dev mode pre-abilitato: su Chrome 135-137 sblocca chrome.userScripts;
|
|
50
|
+
// su versioni successive execute_js usa comunque il fallback new Function.
|
|
51
|
+
await mkdir(join(profileDir, 'Default'), { recursive: true });
|
|
52
|
+
await writeFile(join(profileDir, 'Default', 'Preferences'),
|
|
53
|
+
JSON.stringify({ extensions: { ui: { developer_mode: true } } }));
|
|
54
|
+
|
|
55
|
+
return { base, extDir, profileDir };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Avvia il browser. Ritorna { pid, stop } — stop() termina il processo e
|
|
60
|
+
* rimuove le directory temporanee.
|
|
61
|
+
*/
|
|
62
|
+
export async function launchBrowser({ port, headless = false }) {
|
|
63
|
+
const browser = findBrowser();
|
|
64
|
+
const { base, extDir, profileDir } = await prepareLaunch(port);
|
|
65
|
+
|
|
66
|
+
const args = [
|
|
67
|
+
`--user-data-dir=${profileDir}`,
|
|
68
|
+
`--load-extension=${extDir}`,
|
|
69
|
+
'--no-first-run',
|
|
70
|
+
'--no-default-browser-check',
|
|
71
|
+
'--hide-crash-restore-bubble',
|
|
72
|
+
'--disable-gpu',
|
|
73
|
+
'--window-size=1280,800',
|
|
74
|
+
];
|
|
75
|
+
if (headless) args.push('--headless=new');
|
|
76
|
+
args.push('about:blank');
|
|
77
|
+
|
|
78
|
+
const proc = spawn(browser, args, { stdio: 'ignore', detached: false });
|
|
79
|
+
proc.on('error', (err) => console.error(`[chrome-bridge] browser process error: ${err.message}`));
|
|
80
|
+
|
|
81
|
+
let stopped = false;
|
|
82
|
+
const stop = async () => {
|
|
83
|
+
if (stopped) return;
|
|
84
|
+
stopped = true;
|
|
85
|
+
if (proc.exitCode === null) {
|
|
86
|
+
proc.kill('SIGTERM');
|
|
87
|
+
await new Promise((resolve) => {
|
|
88
|
+
const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} resolve(); }, 3000);
|
|
89
|
+
proc.once('exit', () => { clearTimeout(t); resolve(); });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await rm(base, { recursive: true, force: true }).catch(() => {});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
console.error(`[chrome-bridge] launched ${browser}${headless ? ' (headless)' : ''} pid=${proc.pid}, ws port ${port}`);
|
|
96
|
+
return { pid: proc.pid, stop };
|
|
97
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifica HTTP dei link lato server (niente limiti CORS della pagina).
|
|
3
|
+
* HEAD con fallback GET per server che non supportano HEAD.
|
|
4
|
+
*
|
|
5
|
+
* Nota SSRF: i link arrivano dalla pagina aperta dall'utente (tool locale di
|
|
6
|
+
* sviluppo); nessun filtro su IP privati. Solo http/https.
|
|
7
|
+
*
|
|
8
|
+
* Nota: HEAD 403 da bot-protection può causare falsi positivi; il fallback
|
|
9
|
+
* GET scatta solo su 405/501 by design.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
async function checkOne({ url, text }, timeoutMs) {
|
|
13
|
+
// Scheme guard: solo http/https supportati
|
|
14
|
+
try {
|
|
15
|
+
const proto = new URL(url).protocol;
|
|
16
|
+
if (proto !== 'http:' && proto !== 'https:') {
|
|
17
|
+
return { url, text, status: 0, ok: false, broken: true, error: 'Unsupported scheme' };
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return { url, text, status: 0, ok: false, broken: true, error: 'Invalid URL' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let lastError = null;
|
|
24
|
+
for (const method of ['HEAD', 'GET']) {
|
|
25
|
+
const ctrl = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
27
|
+
try {
|
|
28
|
+
const resp = await fetch(url, { method, redirect: 'follow', signal: ctrl.signal });
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
if (method === 'HEAD' && (resp.status === 405 || resp.status === 501)) continue;
|
|
31
|
+
if (method === 'GET') await resp.body?.cancel();
|
|
32
|
+
return { url, text, status: resp.status, ok: resp.ok, broken: resp.status >= 400 };
|
|
33
|
+
} catch (err) {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
if (err.name === 'AbortError' || err.cause?.name === 'AbortError') {
|
|
36
|
+
lastError = `timeout after ${timeoutMs}ms`;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
lastError = err.cause?.message || err.message || 'Network error';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { url, text, status: 0, ok: false, broken: true, error: lastError };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function checkLinksBatch(links, timeoutMs = 5000, concurrency = 10) {
|
|
46
|
+
const results = new Array(links.length);
|
|
47
|
+
let next = 0;
|
|
48
|
+
async function worker() {
|
|
49
|
+
while (next < links.length) {
|
|
50
|
+
const idx = next++;
|
|
51
|
+
results[idx] = await checkOne(links[idx], timeoutMs);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const workers = Array.from({ length: Math.min(concurrency, links.length) }, worker);
|
|
55
|
+
await Promise.all(workers);
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Costanti del protocollo e helper per la comunicazione WebSocket.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// Tipi di messaggi WebSocket
|
|
8
|
+
export const MessageType = Object.freeze({
|
|
9
|
+
// Comandi (server → extension)
|
|
10
|
+
NAVIGATE: 'navigate',
|
|
11
|
+
SCREENSHOT: 'screenshot',
|
|
12
|
+
EXECUTE_JS: 'execute_js',
|
|
13
|
+
CLICK: 'click',
|
|
14
|
+
TYPE_TEXT: 'type_text',
|
|
15
|
+
READ_PAGE: 'read_page',
|
|
16
|
+
GET_TABS: 'get_tabs',
|
|
17
|
+
GET_PAGE_INFO: 'get_page_info',
|
|
18
|
+
GET_STORAGE: 'get_storage',
|
|
19
|
+
GET_PERFORMANCE: 'get_performance',
|
|
20
|
+
QUERY_DOM: 'query_dom',
|
|
21
|
+
MODIFY_DOM: 'modify_dom',
|
|
22
|
+
INJECT_CSS: 'inject_css',
|
|
23
|
+
READ_CONSOLE: 'read_console',
|
|
24
|
+
MONITOR_NETWORK: 'monitor_network',
|
|
25
|
+
CREATE_TAB: 'create_tab',
|
|
26
|
+
WAIT_FOR_ELEMENT: 'wait_for_element',
|
|
27
|
+
SCROLL_TO: 'scroll_to',
|
|
28
|
+
SET_STORAGE: 'set_storage',
|
|
29
|
+
FILL_FORM: 'fill_form',
|
|
30
|
+
VIEWPORT_RESIZE: 'viewport_resize',
|
|
31
|
+
FULL_PAGE_SCREENSHOT: 'full_page_screenshot',
|
|
32
|
+
ELEMENT_SCREENSHOT: 'element_screenshot',
|
|
33
|
+
HIGHLIGHT_ELEMENTS: 'highlight_elements',
|
|
34
|
+
ACCESSIBILITY_AUDIT: 'accessibility_audit',
|
|
35
|
+
COLLECT_LINKS: 'collect_links',
|
|
36
|
+
MEASURE_SPACING: 'measure_spacing',
|
|
37
|
+
WATCH_DOM: 'watch_dom',
|
|
38
|
+
EMULATE_MEDIA: 'emulate_media',
|
|
39
|
+
HOVER: 'hover',
|
|
40
|
+
PRESS_KEY: 'press_key',
|
|
41
|
+
GET_FRAMES: 'get_frames',
|
|
42
|
+
TAB_ACTION: 'tab_action',
|
|
43
|
+
UPLOAD_FILE: 'upload_file',
|
|
44
|
+
WAIT_FOR_NAVIGATION: 'wait_for_navigation',
|
|
45
|
+
WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
|
|
46
|
+
HANDLE_DIALOGS: 'handle_dialogs',
|
|
47
|
+
FIND_TEXT: 'find_text',
|
|
48
|
+
NETWORK_RULES: 'network_rules',
|
|
49
|
+
SCREENSHOT_DIFF: 'screenshot_diff',
|
|
50
|
+
WEB_VITALS: 'web_vitals',
|
|
51
|
+
LIST_EVENT_LISTENERS: 'list_event_listeners',
|
|
52
|
+
MONITOR_WEBSOCKET: 'monitor_websocket',
|
|
53
|
+
SEO_AUDIT: 'seo_audit',
|
|
54
|
+
EXTRACT_TABLE: 'extract_table',
|
|
55
|
+
UNUSED_CSS: 'unused_css',
|
|
56
|
+
DRAG_AND_DROP: 'drag_and_drop',
|
|
57
|
+
CLIPBOARD: 'clipboard',
|
|
58
|
+
SET_GEOLOCATION: 'set_geolocation',
|
|
59
|
+
MANAGE_DOWNLOADS: 'manage_downloads',
|
|
60
|
+
SAVE_PAGE: 'save_page',
|
|
61
|
+
SET_ZOOM: 'set_zoom',
|
|
62
|
+
HTTP_AUTH: 'http_auth',
|
|
63
|
+
GET_RESPONSE_HEADERS: 'get_response_headers',
|
|
64
|
+
GET_INTERACTIVES: 'get_interactives',
|
|
65
|
+
WAIT_FOR_FUNCTION: 'wait_for_function',
|
|
66
|
+
SCROLL_UNTIL: 'scroll_until',
|
|
67
|
+
DISMISS_OVERLAYS: 'dismiss_overlays',
|
|
68
|
+
|
|
69
|
+
// Risposte (extension → server)
|
|
70
|
+
RESULT: 'result',
|
|
71
|
+
ERROR: 'error',
|
|
72
|
+
|
|
73
|
+
// Heartbeat
|
|
74
|
+
PING: 'ping',
|
|
75
|
+
PONG: 'pong',
|
|
76
|
+
|
|
77
|
+
// Handshake identificazione connessione
|
|
78
|
+
EXT_INIT: 'ext_init',
|
|
79
|
+
RELAY_INIT: 'relay_init',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Versione
|
|
83
|
+
export const VERSION = '1.8.0';
|
|
84
|
+
|
|
85
|
+
// Configurazione
|
|
86
|
+
export const DEFAULT_PORT = 8765;
|
|
87
|
+
export const COMMAND_TIMEOUT_MS = 30000; // 30s per comandi normali
|
|
88
|
+
export const SCREENSHOT_TIMEOUT_MS = 10000; // 10s per screenshot
|
|
89
|
+
export const PING_INTERVAL_MS = 15000; // 15s heartbeat
|
|
90
|
+
export const IDENT_TIMEOUT_MS = 5000; // tempo max per identificarsi
|
|
91
|
+
export const PENDING_RELAY_TTL_MS = 150000; // deve superare il timeout comando più lungo (120s full_page_screenshot)
|
|
92
|
+
|
|
93
|
+
// Entropia per instance + contatore globale per ID univoci
|
|
94
|
+
// 4 byte casuali per processo (2^32 valori): sufficiente per un bridge locale
|
|
95
|
+
const instanceId = randomBytes(4).toString('hex');
|
|
96
|
+
let messageCounter = 0;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Crea un oggetto comando da inviare all'estensione.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} type - Tipo di comando (da MessageType)
|
|
102
|
+
* @param {object} params - Parametri del comando
|
|
103
|
+
* @returns {object} Comando serializzabile in JSON
|
|
104
|
+
*/
|
|
105
|
+
export function createCommand(type, params = {}) {
|
|
106
|
+
messageCounter += 1;
|
|
107
|
+
return {
|
|
108
|
+
id: `msg_${instanceId}_${messageCounter}`,
|
|
109
|
+
type,
|
|
110
|
+
params,
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Restituisce il timeout appropriato per un tipo di comando.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} type - Tipo di comando
|
|
119
|
+
* @returns {number} Timeout in millisecondi
|
|
120
|
+
*/
|
|
121
|
+
export function getTimeout(type) {
|
|
122
|
+
if (type === MessageType.SCREENSHOT
|
|
123
|
+
|| type === MessageType.ELEMENT_SCREENSHOT
|
|
124
|
+
|| type === MessageType.SCREENSHOT_DIFF) return SCREENSHOT_TIMEOUT_MS;
|
|
125
|
+
if (type === MessageType.FULL_PAGE_SCREENSHOT) return 120000;
|
|
126
|
+
if (type === MessageType.WAIT_FOR_ELEMENT
|
|
127
|
+
|| type === MessageType.WAIT_FOR_NAVIGATION
|
|
128
|
+
|| type === MessageType.WAIT_FOR_NETWORK_IDLE
|
|
129
|
+
|| type === MessageType.UPLOAD_FILE
|
|
130
|
+
|| type === MessageType.MANAGE_DOWNLOADS
|
|
131
|
+
|| type === MessageType.SAVE_PAGE
|
|
132
|
+
|| type === MessageType.WAIT_FOR_FUNCTION
|
|
133
|
+
|| type === MessageType.SCROLL_UNTIL) return 60000;
|
|
134
|
+
return COMMAND_TIMEOUT_MS;
|
|
135
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valutazione lato server degli header di sicurezza HTTP
|
|
3
|
+
* (header raccolti dall'estensione via webRequest).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function evaluateSecurityHeaders(headers, url) {
|
|
7
|
+
// headers: oggetto con chiavi lowercase
|
|
8
|
+
const findings = [];
|
|
9
|
+
const isHttps = url.startsWith('https:');
|
|
10
|
+
const add = (severity, header, message) => findings.push({ severity, header, message });
|
|
11
|
+
|
|
12
|
+
const csp = headers['content-security-policy'];
|
|
13
|
+
// Wildcard "nuda" (token * isolato), non sottodomini tipo *.example.com
|
|
14
|
+
const cspBareWildcard = !!csp && /default-src[^;]*(?:^|\s)\*(?=\s|;|$)/.test(csp);
|
|
15
|
+
if (!csp) {
|
|
16
|
+
add('warning', 'content-security-policy', 'No Content-Security-Policy header');
|
|
17
|
+
} else {
|
|
18
|
+
if (csp.includes("'unsafe-inline'")) add('warning', 'content-security-policy', "CSP allows 'unsafe-inline'");
|
|
19
|
+
if (csp.includes("'unsafe-eval'")) add('warning', 'content-security-policy', "CSP allows 'unsafe-eval'");
|
|
20
|
+
if (cspBareWildcard) add('warning', 'content-security-policy', 'CSP default-src allows any origin (*)');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isHttps && !headers['strict-transport-security']) {
|
|
24
|
+
add('warning', 'strict-transport-security', 'No Strict-Transport-Security header (HSTS)');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!headers['x-content-type-options'] || headers['x-content-type-options'].toLowerCase() !== 'nosniff') {
|
|
28
|
+
add('warning', 'x-content-type-options', 'X-Content-Type-Options: nosniff missing');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hasFrameProtection = headers['x-frame-options'] || (csp && csp.includes('frame-ancestors'));
|
|
32
|
+
if (!hasFrameProtection) {
|
|
33
|
+
add('warning', 'x-frame-options', 'No clickjacking protection (X-Frame-Options or CSP frame-ancestors)');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!headers['referrer-policy']) {
|
|
37
|
+
add('info', 'referrer-policy', 'No Referrer-Policy header');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!headers['permissions-policy']) {
|
|
41
|
+
add('info', 'permissions-policy', 'No Permissions-Policy header');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const server = headers['server'];
|
|
45
|
+
if (server && /\/[\d.]+/.test(server)) {
|
|
46
|
+
add('info', 'server', `Server header leaks software version: "${server}"`);
|
|
47
|
+
}
|
|
48
|
+
const powered = headers['x-powered-by'];
|
|
49
|
+
if (powered) {
|
|
50
|
+
add('info', 'x-powered-by', `X-Powered-By header leaks stack: "${powered}"`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
url,
|
|
55
|
+
findings,
|
|
56
|
+
summary: {
|
|
57
|
+
errors: findings.filter((f) => f.severity === 'error').length,
|
|
58
|
+
warnings: findings.filter((f) => f.severity === 'warning').length,
|
|
59
|
+
info: findings.filter((f) => f.severity === 'info').length,
|
|
60
|
+
},
|
|
61
|
+
grade_hints: {
|
|
62
|
+
csp: !!csp && !csp.includes("'unsafe-inline'") && !csp.includes("'unsafe-eval'") && !cspBareWildcard,
|
|
63
|
+
hsts: !isHttps || !!headers['strict-transport-security'],
|
|
64
|
+
nosniff: headers['x-content-type-options']?.toLowerCase() === 'nosniff',
|
|
65
|
+
clickjacking: !!hasFrameProtection,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mini server HTTP per il response stubbing di network_rules.
|
|
3
|
+
*
|
|
4
|
+
* declarativeNetRequest può bloccare/redirigere ma non sintetizzare body:
|
|
5
|
+
* le richieste da stubbare vengono redirette qui, dove serviamo la risposta
|
|
6
|
+
* registrata (CORS aperto). Porta effimera, avviato al primo stub.
|
|
7
|
+
*
|
|
8
|
+
* Nota mixed-content: da pagine HTTPS il browser blocca redirect verso
|
|
9
|
+
* http:// non-trustworthy. 127.0.0.1 è trattato come sicuro; su ChromeOS
|
|
10
|
+
* (host penguin.linux.test) gli stub valgono per pagine http/dev server.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { hostname } from 'node:os';
|
|
15
|
+
|
|
16
|
+
const stubs = new Map();
|
|
17
|
+
let server = null;
|
|
18
|
+
let port = null;
|
|
19
|
+
let seq = 0;
|
|
20
|
+
|
|
21
|
+
/** Host raggiungibile dal browser: override env, poi euristica Crostini. */
|
|
22
|
+
export function stubHost() {
|
|
23
|
+
if (process.env.CHROME_BRIDGE_STUB_HOST) return process.env.CHROME_BRIDGE_STUB_HOST;
|
|
24
|
+
return hostname() === 'penguin' ? 'penguin.linux.test' : '127.0.0.1';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function ensureStubServer() {
|
|
28
|
+
if (server) return port;
|
|
29
|
+
server = createServer((req, res) => {
|
|
30
|
+
const cors = {
|
|
31
|
+
'Access-Control-Allow-Origin': '*',
|
|
32
|
+
'Access-Control-Allow-Methods': '*',
|
|
33
|
+
'Access-Control-Allow-Headers': '*',
|
|
34
|
+
};
|
|
35
|
+
if (req.method === 'OPTIONS') {
|
|
36
|
+
res.writeHead(204, cors);
|
|
37
|
+
res.end();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const id = (req.url || '').replace(/^\/__stub__\//, '').split('?')[0];
|
|
41
|
+
const stub = stubs.get(id);
|
|
42
|
+
if (!stub) {
|
|
43
|
+
res.writeHead(404, { ...cors, 'Content-Type': 'text/plain' });
|
|
44
|
+
res.end('stub not found');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
res.writeHead(stub.status, { ...cors, 'Content-Type': stub.content_type });
|
|
48
|
+
res.end(stub.body);
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
server.once('error', reject);
|
|
52
|
+
// 0.0.0.0: su Crostini il browser host entra dall'interfaccia del container
|
|
53
|
+
server.listen(0, '0.0.0.0', resolve);
|
|
54
|
+
});
|
|
55
|
+
server.unref();
|
|
56
|
+
port = server.address().port;
|
|
57
|
+
return port;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function addStub({ body, status = 200, content_type = 'application/json' }) {
|
|
61
|
+
const id = `s${++seq}`;
|
|
62
|
+
stubs.set(id, { body, status, content_type });
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function clearStubs() {
|
|
67
|
+
stubs.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function listStubs() {
|
|
71
|
+
return [...stubs.entries()].map(([id, s]) => ({ id, status: s.status, content_type: s.content_type, bytes: Buffer.byteLength(s.body) }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function stopStubServer() {
|
|
75
|
+
stubs.clear();
|
|
76
|
+
if (server) {
|
|
77
|
+
await new Promise((r) => server.close(r));
|
|
78
|
+
server = null;
|
|
79
|
+
port = null;
|
|
80
|
+
}
|
|
81
|
+
}
|