@phi-code-admin/camofox-browser 1.0.1 → 1.0.3
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/lib/auth.js +21 -6
- package/lib/plugins.js +5 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/server.js +59 -5
package/lib/auth.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* Policy (accessKeyMiddleware / global):
|
|
14
14
|
* - If CAMOFOX_ACCESS_KEY is set, require Bearer match on all routes except
|
|
15
15
|
* /health, cookie import (when CAMOFOX_API_KEY set), and /stop (when CAMOFOX_ADMIN_KEY set).
|
|
16
|
-
* - If
|
|
16
|
+
* - If no key (access nor api) is set AND NODE_ENV === production, fail closed:
|
|
17
|
+
* reject every non-/health request with 503 instead of fail-open passthrough.
|
|
18
|
+
* - Otherwise (non-production, no key), pass through (backward-compatible).
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import crypto from 'crypto';
|
|
@@ -101,18 +103,31 @@ export function requireAuth(config, options = {}) {
|
|
|
101
103
|
* When a route's dedicated key is NOT configured, the access-key middleware
|
|
102
104
|
* does NOT exempt it -- defense-in-depth prevents unprotected endpoints.
|
|
103
105
|
*
|
|
104
|
-
* When
|
|
106
|
+
* When no key is configured: in production this fails closed (503 for every
|
|
107
|
+
* non-/health request) so a network-exposed deployment is never an open,
|
|
108
|
+
* unauthenticated browser-control service; outside production it passes through
|
|
109
|
+
* (backward-compatible for local development).
|
|
105
110
|
*
|
|
106
|
-
* @param {object} config - Must have { accessKey }; optionally { apiKey, adminKey }
|
|
111
|
+
* @param {object} config - Must have { accessKey }; optionally { apiKey, adminKey, nodeEnv }
|
|
107
112
|
* @returns {function} Express middleware (req, res, next)
|
|
108
113
|
*/
|
|
109
114
|
export function accessKeyMiddleware(config) {
|
|
110
115
|
return function accessKeyCheck(req, res, next) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Exempt healthcheck
|
|
116
|
+
// Exempt healthcheck unconditionally so liveness probes always work.
|
|
114
117
|
if (req.path === '/health') return next();
|
|
115
118
|
|
|
119
|
+
if (!config.accessKey) {
|
|
120
|
+
// Fail closed in production when no credential at all is configured.
|
|
121
|
+
// Without this, an exposed deployment would serve the entire
|
|
122
|
+
// browser-driving surface with zero authentication.
|
|
123
|
+
if (config.nodeEnv === 'production' && !config.apiKey) {
|
|
124
|
+
return res.status(503).json({
|
|
125
|
+
error: 'Server is not configured for authenticated access. Set CAMOFOX_ACCESS_KEY (or CAMOFOX_API_KEY) before serving in production.',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return next();
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
// Exempt routes with their own dedicated auth -- but only when their key is configured.
|
|
117
132
|
// If the dedicated key is NOT set, the access key gates the route (defense-in-depth).
|
|
118
133
|
if (config.apiKey && req.method === 'POST' && /^\/sessions\/[^/]+\/cookies$/.test(req.path)) return next();
|
package/lib/plugins.js
CHANGED
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
import { EventEmitter } from 'events';
|
|
61
61
|
import fs from 'fs';
|
|
62
62
|
import path from 'path';
|
|
63
|
-
import { fileURLToPath } from 'url';
|
|
63
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
64
64
|
|
|
65
65
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
66
66
|
const ROOT_DIR = path.join(__dirname, '..');
|
|
@@ -155,7 +155,10 @@ export async function loadPlugins(app, ctx) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
try {
|
|
158
|
-
|
|
158
|
+
// PHI-VENDOR: on Windows, Node's ESM loader refuses raw absolute paths
|
|
159
|
+
// (e.g. "C:\foo\plugin.js") and demands a file:// URL. Convert before
|
|
160
|
+
// calling dynamic import so plugin loading works cross-platform.
|
|
161
|
+
const mod = await import(pathToFileURL(indexPath).href);
|
|
159
162
|
const register = mod.default || mod.register;
|
|
160
163
|
if (typeof register !== 'function') {
|
|
161
164
|
ctx.log('warn', `plugin "${name}" does not export a register function, skipping`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "@skyfallsin/camofox-browser",
|
|
3
3
|
"name": "Camofox Browser",
|
|
4
4
|
"description": "Anti-detection browser automation for AI agents using Camoufox (Firefox-based)",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.0.3",
|
|
6
6
|
"envVars": {
|
|
7
7
|
"CAMOFOX_API_KEY": {
|
|
8
8
|
"description": "Secret key for the cookie-import endpoint. Cookie import is disabled when unset. Only set this if you need to import browser cookies and the server is local or access-controlled.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phi-code-admin/camofox-browser",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Phi-code vendored headless browser automation library (snapshot of jo-inc/camofox-browser@c9a90daf, MIT). 10 OpenClaw browser tools exposed as ES module functions; legacy Express server kept opt-in.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
"fetch-bin": "node -e \"console.warn('@phi-code-admin/camofox-browser bundles the binary via @phi-code-admin/camoufox-bin-*; no fetch needed. Reinstall if missing.')\""
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@phi-code-admin/camoufox-js": "1.0.
|
|
136
|
+
"@phi-code-admin/camoufox-js": "1.0.1",
|
|
137
137
|
"express": "^4.18.2",
|
|
138
138
|
"playwright-core": "^1.58.0",
|
|
139
139
|
"prom-client": "^15.1.3",
|
package/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { VirtualDisplay } from '@phi-code-admin/camoufox-js/dist/virtdisplay.js'
|
|
|
6
6
|
import { firefox } from 'playwright-core';
|
|
7
7
|
import express from 'express';
|
|
8
8
|
import crypto from 'crypto';
|
|
9
|
+
import net from 'net';
|
|
9
10
|
import fs from 'fs';
|
|
10
11
|
import os from 'os';
|
|
11
12
|
import { expandMacro } from './lib/macros.js';
|
|
@@ -210,12 +211,56 @@ function sendError(res, err, extraFields = {}) {
|
|
|
210
211
|
res.status(status).json(body);
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// SSRF guard: returns true when an IPv4/IPv6 address belongs to a range that
|
|
215
|
+
// must never be reachable from the browser agent (loopback, link-local, the
|
|
216
|
+
// cloud-metadata endpoint, RFC1918, CGNAT, ULA, unspecified/multicast).
|
|
217
|
+
// Kept self-contained (no external dep) and best-effort: it blocks IP literals
|
|
218
|
+
// and obviously-internal hostnames before navigation. DNS-rebinding still needs
|
|
219
|
+
// proxy-level egress control, but this closes the trivial SSRF-via-literal vector.
|
|
220
|
+
function isBlockedIp(address) {
|
|
221
|
+
const family = net.isIP(address);
|
|
222
|
+
if (family === 4) {
|
|
223
|
+
const parts = address.split('.').map(n => parseInt(n, 10));
|
|
224
|
+
if (parts.length !== 4 || parts.some(n => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
|
225
|
+
const [a, b] = parts;
|
|
226
|
+
if (a === 0) return true; // 0.0.0.0/8 (unspecified)
|
|
227
|
+
if (a === 127) return true; // loopback
|
|
228
|
+
if (a === 10) return true; // RFC1918
|
|
229
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
230
|
+
if (a === 192 && b === 168) return true; // RFC1918
|
|
231
|
+
if (a === 169 && b === 254) return true; // link-local + metadata (169.254.169.254)
|
|
232
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
|
|
233
|
+
if (a >= 224) return true; // multicast/reserved (224.0.0.0+)
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (family === 6) {
|
|
237
|
+
const norm = address.toLowerCase().replace(/^\[|\]$/g, '');
|
|
238
|
+
// IPv4-mapped (::ffff:a.b.c.d) -> re-check the embedded IPv4
|
|
239
|
+
const mapped = norm.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
240
|
+
if (mapped) return isBlockedIp(mapped[1]);
|
|
241
|
+
if (norm === '::1' || norm === '::') return true; // loopback / unspecified
|
|
242
|
+
if (norm.startsWith('fe80')) return true; // link-local
|
|
243
|
+
if (norm.startsWith('fc') || norm.startsWith('fd')) return true; // ULA fc00::/7
|
|
244
|
+
if (norm.startsWith('ff')) return true; // multicast
|
|
245
|
+
if (norm.startsWith('fd00:ec2')) return true; // AWS IPv6 metadata
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
213
251
|
function validateUrl(url) {
|
|
214
252
|
try {
|
|
215
253
|
const parsed = new URL(url);
|
|
216
254
|
if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
|
|
217
255
|
return `Blocked URL scheme: ${parsed.protocol} (only http/https allowed)`;
|
|
218
256
|
}
|
|
257
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
258
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
259
|
+
return `Blocked host: ${parsed.hostname} (internal/metadata addresses are not allowed)`;
|
|
260
|
+
}
|
|
261
|
+
if (net.isIP(hostname) && isBlockedIp(hostname)) {
|
|
262
|
+
return `Blocked host: ${parsed.hostname} (internal/metadata addresses are not allowed)`;
|
|
263
|
+
}
|
|
219
264
|
return null;
|
|
220
265
|
} catch {
|
|
221
266
|
return `Invalid URL: ${url}`;
|
|
@@ -4340,7 +4385,7 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
|
4340
4385
|
* schema:
|
|
4341
4386
|
* $ref: '#/components/schemas/Error'
|
|
4342
4387
|
*/
|
|
4343
|
-
app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
|
|
4388
|
+
app.post('/tabs/:tabId/evaluate', authMiddleware(), express.json({ limit: '1mb' }), async (req, res) => {
|
|
4344
4389
|
try {
|
|
4345
4390
|
const { userId, expression } = req.body;
|
|
4346
4391
|
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
@@ -5963,7 +6008,16 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
5963
6008
|
// Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
|
|
5964
6009
|
|
|
5965
6010
|
const PORT = CONFIG.port;
|
|
5966
|
-
|
|
6011
|
+
// Bind host: default to loopback so the browser-control API is not exposed on
|
|
6012
|
+
// the LAN/container network out of the box. Container/cloud deploys must set
|
|
6013
|
+
// CAMOFOX_HOST=0.0.0.0 explicitly (the platform controls ingress there).
|
|
6014
|
+
const HOST = (process.env.CAMOFOX_HOST || '127.0.0.1').trim();
|
|
6015
|
+
// Loud warning when binding to a non-loopback interface without any auth key.
|
|
6016
|
+
if (HOST !== '127.0.0.1' && HOST !== '::1' && HOST !== 'localhost'
|
|
6017
|
+
&& !CONFIG.accessKey && !CONFIG.apiKey) {
|
|
6018
|
+
log('warn', 'binding to non-loopback host without CAMOFOX_ACCESS_KEY/CAMOFOX_API_KEY -- browser-control API is exposed unauthenticated', { host: HOST });
|
|
6019
|
+
}
|
|
6020
|
+
pluginEvents.emit('server:starting', { port: PORT, host: HOST });
|
|
5967
6021
|
|
|
5968
6022
|
// Load plugins before starting the server
|
|
5969
6023
|
const pluginCtx = {
|
|
@@ -5996,15 +6050,15 @@ const loadedPlugins = await loadPlugins(app, pluginCtx);
|
|
|
5996
6050
|
// --- OpenAPI docs (after all routes are registered) ---
|
|
5997
6051
|
mountDocs(app);
|
|
5998
6052
|
|
|
5999
|
-
const server = app.listen(PORT, async () => {
|
|
6053
|
+
const server = app.listen(PORT, HOST, async () => {
|
|
6000
6054
|
startMemoryReporter();
|
|
6001
6055
|
refreshActiveTabsGauge();
|
|
6002
6056
|
refreshTabLockQueueDepth();
|
|
6003
6057
|
pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
|
|
6004
6058
|
if (FLY_MACHINE_ID) {
|
|
6005
|
-
log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
|
|
6059
|
+
log('info', 'server started (fly)', { port: PORT, host: HOST, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
|
|
6006
6060
|
} else {
|
|
6007
|
-
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
6061
|
+
log('info', 'server started', { port: PORT, host: HOST, pid: process.pid, nodeVersion: process.version });
|
|
6008
6062
|
}
|
|
6009
6063
|
const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
|
|
6010
6064
|
if (tmpCleanup.removed > 0) {
|