@askjo/camofox-browser 1.7.3 → 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/README.md +1 -0
- package/lib/auth.js +73 -10
- package/lib/config.js +2 -0
- package/lib/openapi.js +6 -1
- package/lib/reporter.js +19 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.js +8 -2
package/README.md
CHANGED
|
@@ -483,6 +483,7 @@ Reddit macros return JSON directly (no HTML parsing needed):
|
|
|
483
483
|
| `PORT` | Server port (fallback, for platforms like Fly.io) | `9377` |
|
|
484
484
|
| `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | - |
|
|
485
485
|
| `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | - |
|
|
486
|
+
| `CAMOFOX_ACCESS_KEY` | If set, all routes (except `/health`, cookie import, and `/stop`) require `Authorization: Bearer <key>`. Lets you safely expose the server beyond loopback. | - |
|
|
486
487
|
| `CAMOFOX_COOKIES_DIR` | Directory for cookie files | `~/.camofox/cookies` |
|
|
487
488
|
| `CAMOFOX_PROFILE_DIR` | Directory for persisted session profiles | `~/.camofox/profiles` |
|
|
488
489
|
| `CAMOFOX_TRACES_DIR` | Directory for session trace zips | `~/.camofox/traces` |
|
package/lib/auth.js
CHANGED
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
* Extracts the duplicated auth pattern from cookie/storage_state endpoints
|
|
5
5
|
* into a reusable Express middleware factory.
|
|
6
6
|
*
|
|
7
|
-
* Policy:
|
|
7
|
+
* Policy (requireAuth / per-route):
|
|
8
8
|
* - If CAMOFOX_API_KEY is set, require Bearer token match (timing-safe).
|
|
9
|
-
* - If
|
|
9
|
+
* - If CAMOFOX_ACCESS_KEY is set, also accept it as an alternative (superkey).
|
|
10
|
+
* - If neither key set and NODE_ENV !== production, allow loopback (127.0.0.1 / ::1).
|
|
10
11
|
* - Otherwise, reject.
|
|
12
|
+
*
|
|
13
|
+
* Policy (accessKeyMiddleware / global):
|
|
14
|
+
* - If CAMOFOX_ACCESS_KEY is set, require Bearer match on all routes except
|
|
15
|
+
* /health, cookie import (when CAMOFOX_API_KEY set), and /stop (when CAMOFOX_ADMIN_KEY set).
|
|
16
|
+
* - If not set, pass through (backward-compatible).
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import crypto from 'crypto';
|
|
@@ -38,7 +44,12 @@ function isLoopbackAddress(address) {
|
|
|
38
44
|
/**
|
|
39
45
|
* Create an Express middleware that enforces API key auth.
|
|
40
46
|
*
|
|
41
|
-
*
|
|
47
|
+
* Accepts CAMOFOX_API_KEY as primary token. When CAMOFOX_ACCESS_KEY is also
|
|
48
|
+
* configured, it is accepted as an alternative ("superkey") so that routes
|
|
49
|
+
* gated by both the global access-key middleware AND this per-route middleware
|
|
50
|
+
* don't require two different tokens in a single Authorization header.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} config - Must have { apiKey, nodeEnv }; optionally { accessKey }
|
|
42
53
|
* @param {object} [options]
|
|
43
54
|
* @param {string} [options.errorMessage] - Custom error message when rejecting unauthenticated requests
|
|
44
55
|
* @returns {function} Express middleware (req, res, next)
|
|
@@ -47,16 +58,27 @@ export function requireAuth(config, options = {}) {
|
|
|
47
58
|
const errorMessage = options.errorMessage ||
|
|
48
59
|
'This endpoint requires CAMOFOX_API_KEY except for loopback requests in non-production environments.';
|
|
49
60
|
|
|
50
|
-
return (req, res, next)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
return function requireAuthCheck(req, res, next) {
|
|
62
|
+
const auth = String(req.headers['authorization'] || '');
|
|
63
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
64
|
+
const token = match ? match[1]?.trim() : null;
|
|
65
|
+
|
|
66
|
+
// Accept API key
|
|
67
|
+
if (config.apiKey && token && timingSafeCompare(token, config.apiKey)) {
|
|
68
|
+
return next();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Accept access key as alternative (superkey)
|
|
72
|
+
if (config.accessKey && token && timingSafeCompare(token, config.accessKey)) {
|
|
57
73
|
return next();
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
// If any key is configured, a valid token was required — reject
|
|
77
|
+
if (config.apiKey || config.accessKey) {
|
|
78
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// No keys configured — allow loopback in non-production
|
|
60
82
|
const remoteAddress = req.socket?.remoteAddress || '';
|
|
61
83
|
const allowUnauthedLocal = config.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
|
|
62
84
|
if (!allowUnauthedLocal) {
|
|
@@ -67,5 +89,46 @@ export function requireAuth(config, options = {}) {
|
|
|
67
89
|
};
|
|
68
90
|
}
|
|
69
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Global access-key middleware factory.
|
|
94
|
+
*
|
|
95
|
+
* When CAMOFOX_ACCESS_KEY is set, requires `Authorization: Bearer <key>` on
|
|
96
|
+
* every route except:
|
|
97
|
+
* - GET /health (Docker/Fly healthcheck)
|
|
98
|
+
* - POST /sessions/:userId/cookies (only when CAMOFOX_API_KEY is also set — has its own gate)
|
|
99
|
+
* - POST /stop (only when CAMOFOX_ADMIN_KEY is also set — has its own gate)
|
|
100
|
+
*
|
|
101
|
+
* When a route's dedicated key is NOT configured, the access-key middleware
|
|
102
|
+
* does NOT exempt it — defense-in-depth prevents unprotected endpoints.
|
|
103
|
+
*
|
|
104
|
+
* When CAMOFOX_ACCESS_KEY is not set, passes through (backward-compatible).
|
|
105
|
+
*
|
|
106
|
+
* @param {object} config - Must have { accessKey }; optionally { apiKey, adminKey }
|
|
107
|
+
* @returns {function} Express middleware (req, res, next)
|
|
108
|
+
*/
|
|
109
|
+
export function accessKeyMiddleware(config) {
|
|
110
|
+
return function accessKeyCheck(req, res, next) {
|
|
111
|
+
if (!config.accessKey) return next();
|
|
112
|
+
|
|
113
|
+
// Exempt healthcheck
|
|
114
|
+
if (req.path === '/health') return next();
|
|
115
|
+
|
|
116
|
+
// Exempt routes with their own dedicated auth — but only when their key is configured.
|
|
117
|
+
// If the dedicated key is NOT set, the access key gates the route (defense-in-depth).
|
|
118
|
+
if (config.apiKey && req.method === 'POST' && /^\/sessions\/[^/]+\/cookies$/.test(req.path)) return next();
|
|
119
|
+
if (config.adminKey && req.method === 'POST' && req.path === '/stop') return next();
|
|
120
|
+
|
|
121
|
+
const auth = String(req.headers['authorization'] || '');
|
|
122
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
123
|
+
const token = match ? match[1]?.trim() : null;
|
|
124
|
+
if (!token || !timingSafeCompare(token, config.accessKey)) {
|
|
125
|
+
return res.status(401)
|
|
126
|
+
.set('WWW-Authenticate', 'Bearer realm="camofox"')
|
|
127
|
+
.json({ error: 'Unauthorized' });
|
|
128
|
+
}
|
|
129
|
+
next();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
70
133
|
// Re-export utilities so server.js can still use them directly
|
|
71
134
|
export { timingSafeCompare, isLoopbackAddress };
|
package/lib/config.js
CHANGED
|
@@ -58,6 +58,7 @@ function loadConfig() {
|
|
|
58
58
|
flyApiToken: process.env.FLY_API_TOKEN || '',
|
|
59
59
|
adminKey: process.env.CAMOFOX_ADMIN_KEY || '',
|
|
60
60
|
apiKey: process.env.CAMOFOX_API_KEY || '',
|
|
61
|
+
accessKey: (process.env.CAMOFOX_ACCESS_KEY || '').trim(),
|
|
61
62
|
cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
|
|
62
63
|
profileDir: process.env.CAMOFOX_PROFILE_DIR || join(os.homedir(), '.camofox', 'profiles'),
|
|
63
64
|
tracesDir: process.env.CAMOFOX_TRACES_DIR || join(os.homedir(), '.camofox', 'traces'),
|
|
@@ -97,6 +98,7 @@ function loadConfig() {
|
|
|
97
98
|
NODE_ENV: process.env.NODE_ENV,
|
|
98
99
|
CAMOFOX_ADMIN_KEY: process.env.CAMOFOX_ADMIN_KEY,
|
|
99
100
|
CAMOFOX_API_KEY: process.env.CAMOFOX_API_KEY,
|
|
101
|
+
CAMOFOX_ACCESS_KEY: process.env.CAMOFOX_ACCESS_KEY,
|
|
100
102
|
CAMOFOX_COOKIES_DIR: process.env.CAMOFOX_COOKIES_DIR,
|
|
101
103
|
CAMOFOX_TRACES_DIR: process.env.CAMOFOX_TRACES_DIR,
|
|
102
104
|
CAMOFOX_TRACES_MAX_BYTES: process.env.CAMOFOX_TRACES_MAX_BYTES,
|
package/lib/openapi.js
CHANGED
|
@@ -52,7 +52,12 @@ const swaggerDefinition = {
|
|
|
52
52
|
BearerAuth: {
|
|
53
53
|
type: 'http',
|
|
54
54
|
scheme: 'bearer',
|
|
55
|
-
description: 'Bearer token matching CAMOFOX_API_KEY.',
|
|
55
|
+
description: 'Bearer token matching CAMOFOX_API_KEY (per-route auth for sensitive endpoints like cookie import and traces).',
|
|
56
|
+
},
|
|
57
|
+
AccessKeyAuth: {
|
|
58
|
+
type: 'http',
|
|
59
|
+
scheme: 'bearer',
|
|
60
|
+
description: 'Bearer token matching CAMOFOX_ACCESS_KEY. When set, gates all routes except /health, cookie import, and /stop. Acts as a superkey — also accepted by endpoints that normally require CAMOFOX_API_KEY.',
|
|
56
61
|
},
|
|
57
62
|
},
|
|
58
63
|
schemas: {
|
package/lib/reporter.js
CHANGED
|
@@ -789,7 +789,13 @@ export function createReporter(config) {
|
|
|
789
789
|
|
|
790
790
|
const enabled = config.crashReportEnabled !== false && !!_GH_APP_ID;
|
|
791
791
|
const repo = config.crashReportRepo || cr.repo || 'jo-inc/camofox-browser';
|
|
792
|
-
const
|
|
792
|
+
const rateLimiters = {
|
|
793
|
+
crash: new RateLimiter(5), // 5 crashes/hr
|
|
794
|
+
hang: new RateLimiter(5), // 5 hangs/hr
|
|
795
|
+
stuck: new RateLimiter(2), // 2 stalls/hr (with active tabs only)
|
|
796
|
+
leak: new RateLimiter(2), // 2 leak alerts/hr
|
|
797
|
+
_default: new RateLimiter(config.crashReportRateLimit || 10),
|
|
798
|
+
};
|
|
793
799
|
const version = config.version || 'unknown';
|
|
794
800
|
|
|
795
801
|
let watchdogInterval = null;
|
|
@@ -816,7 +822,9 @@ export function createReporter(config) {
|
|
|
816
822
|
|
|
817
823
|
/** Core: file or deduplicate a report. NEVER throws. */
|
|
818
824
|
async function fileReport(type, labels, detail) {
|
|
819
|
-
|
|
825
|
+
const bucket = type.startsWith('stuck:') ? 'stuck' : type.startsWith('hang:') ? 'hang' : type.startsWith('leak:') ? 'leak' : 'crash';
|
|
826
|
+
const limiter = rateLimiters[bucket] || rateLimiters._default;
|
|
827
|
+
if (!limiter.tryAcquire()) return;
|
|
820
828
|
|
|
821
829
|
const reportPromise = (async () => {
|
|
822
830
|
try {
|
|
@@ -990,7 +998,7 @@ export function createReporter(config) {
|
|
|
990
998
|
|
|
991
999
|
// Suppress false positives from OS sleep/suspend (laptop lid close, VM pause).
|
|
992
1000
|
// Stalls > 120s are almost certainly not event-loop bugs.
|
|
993
|
-
const MAX_REPORTABLE_DRIFT_MS =
|
|
1001
|
+
const MAX_REPORTABLE_DRIFT_MS = 60_000;
|
|
994
1002
|
let suppressTicksRemaining = 0;
|
|
995
1003
|
const SUPPRESS_TICKS_AFTER_WAKE = 5;
|
|
996
1004
|
|
|
@@ -1100,6 +1108,11 @@ export function createReporter(config) {
|
|
|
1100
1108
|
// Remove resourceOpts from extra so it doesn't end up in context
|
|
1101
1109
|
delete extra.resourceOpts;
|
|
1102
1110
|
|
|
1111
|
+
// Don't report idle-server stalls — no user impact
|
|
1112
|
+
if ((resources.activeTabs || 0) === 0 && (resources.browserContexts || 0) === 0) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1103
1116
|
// Event loop delay histogram snapshot
|
|
1104
1117
|
let elDelay = null;
|
|
1105
1118
|
if (elHistogram) {
|
|
@@ -1118,6 +1131,8 @@ export function createReporter(config) {
|
|
|
1118
1131
|
|
|
1119
1132
|
fileReport('stuck:event-loop', labels, {
|
|
1120
1133
|
message: `Event loop stalled for ${Math.round(drift / 1000)}s (threshold: ${Math.round(thresholdMs / 1000)}s)`,
|
|
1134
|
+
// Stable signature: duration is NOT included — all stalls on the same route dedup
|
|
1135
|
+
error: { name: 'EventLoopStall', message: _lastRoute || 'idle', stack: '' },
|
|
1121
1136
|
uptimeMinutes: typeof process !== 'undefined'
|
|
1122
1137
|
? Math.round(process.uptime() / 60) : undefined,
|
|
1123
1138
|
resources,
|
|
@@ -1177,6 +1192,6 @@ export function createReporter(config) {
|
|
|
1177
1192
|
resetNativeMemBaseline,
|
|
1178
1193
|
_anonymize: anonymize,
|
|
1179
1194
|
_stackSignature: stackSignature,
|
|
1180
|
-
_rateLimiter:
|
|
1195
|
+
_rateLimiter: rateLimiters,
|
|
1181
1196
|
};
|
|
1182
1197
|
}
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -10,7 +10,7 @@ import { loadConfig } from './lib/config.js';
|
|
|
10
10
|
import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js';
|
|
11
11
|
import { createFlyHelpers } from './lib/fly.js';
|
|
12
12
|
import { createPluginEvents, loadPlugins } from './lib/plugins.js';
|
|
13
|
-
import { requireAuth, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
|
|
13
|
+
import { requireAuth, accessKeyMiddleware, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
|
|
14
14
|
import { windowSnapshot } from './lib/snapshot.js';
|
|
15
15
|
import {
|
|
16
16
|
MAX_DOWNLOAD_INLINE_BYTES,
|
|
@@ -55,7 +55,7 @@ function _browserPid() {
|
|
|
55
55
|
function _resourceOpts() {
|
|
56
56
|
return { sessionCount: sessions.size, tabCount: _countTabs(), browserPid: _browserPid() };
|
|
57
57
|
}
|
|
58
|
-
reporter.startWatchdog(
|
|
58
|
+
reporter.startWatchdog(30_000, () => {
|
|
59
59
|
const summary = [];
|
|
60
60
|
for (const [sid, session] of sessions) {
|
|
61
61
|
const tabUrls = [];
|
|
@@ -142,6 +142,12 @@ const FLY_MACHINE_ID = fly.machineId;
|
|
|
142
142
|
// Route tab requests to the owning machine via fly-replay header.
|
|
143
143
|
app.use('/tabs/:tabId', fly.replayMiddleware(log));
|
|
144
144
|
|
|
145
|
+
// Access-key middleware: gates every route when CAMOFOX_ACCESS_KEY is set.
|
|
146
|
+
// Exempts /health (Docker healthcheck) and routes that have their own
|
|
147
|
+
// dedicated keys (cookie import → CAMOFOX_API_KEY, /stop → CAMOFOX_ADMIN_KEY)
|
|
148
|
+
// so each key gates a distinct surface. When unset, behavior is unchanged.
|
|
149
|
+
app.use(accessKeyMiddleware(CONFIG));
|
|
150
|
+
|
|
145
151
|
const ALLOWED_URL_SCHEMES = ['http:', 'https:'];
|
|
146
152
|
|
|
147
153
|
// Interactive roles to include - exclude combobox to avoid opening complex widgets
|