@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 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 not set, pass through (backward-compatible).
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 CAMOFOX_ACCESS_KEY is not set, passes through (backward-compatible).
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
- if (!config.accessKey) return next();
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
- const mod = await import(indexPath);
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`);
@@ -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.10.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.1",
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.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
- pluginEvents.emit('server:starting', { port: PORT });
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) {