@chatpanel/gateway 0.6.29 → 0.6.31
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/package.json +2 -2
- package/src/config.js +3 -6
- package/src/configstore.js +3 -5
- package/src/entitlement-refresh.js +8 -18
- package/src/entitlement.js +3 -8
- package/src/freegate.js +9 -18
- package/src/gateway-token.js +60 -0
- package/src/model-hashes.json +3 -0
- package/src/model-integrity.js +82 -0
- package/src/ner-engine.js +15 -0
- package/src/redact.js +5 -5
- package/src/router.js +3 -3
- package/src/secure-fetch.js +58 -0
- package/src/server.js +47 -18
- package/src/stt-engine.js +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.31",
|
|
4
4
|
"description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=18"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@chatpanel/pii": "^0.2.
|
|
30
|
+
"@chatpanel/pii": "^0.2.14",
|
|
31
31
|
"@huggingface/transformers": "^4.2.0",
|
|
32
32
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
33
33
|
},
|
package/src/config.js
CHANGED
|
@@ -34,12 +34,9 @@ const DEFAULTS = {
|
|
|
34
34
|
allowedOrigins: [],
|
|
35
35
|
maxBodyBytes: 26214400,
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// ChatPanel Pro entitlement token (the same offline-signed token the extension/
|
|
41
|
-
// bridge use) to unlock unlimited usage. `free.used` is the running lifetime
|
|
42
|
-
// count (server-authoritative; persisted here so it survives restarts).
|
|
37
|
+
// `free.used` is the running lifetime redaction count for the free allowance
|
|
38
|
+
// (freegate.FREE_TOTAL_CAP redactions at full tier), persisted here. A valid
|
|
39
|
+
// ChatPanel entitlement token unlocks unlimited use.
|
|
43
40
|
pro: {
|
|
44
41
|
entitlementToken: '',
|
|
45
42
|
free: {
|
package/src/configstore.js
CHANGED
|
@@ -53,8 +53,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
|
|
|
53
53
|
ner: cfg.ner,
|
|
54
54
|
stt: cfg.stt,
|
|
55
55
|
allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
|
|
56
|
-
// free = lifetime trial usage ({ used, cap, remaining })
|
|
57
|
-
// is fixed and the count is server-authoritative (never settable from the UI).
|
|
56
|
+
// free = lifetime trial usage ({ used, cap, remaining }), read-only.
|
|
58
57
|
pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: usage(cfg) },
|
|
59
58
|
logRequests: !!cfg.logRequests,
|
|
60
59
|
logDetail: ['types', 'values'].includes(cfg.logDetail) ? cfg.logDetail : 'off',
|
|
@@ -112,9 +111,8 @@ export function applyConfigPatch(cfg, patch = {}) {
|
|
|
112
111
|
if (Array.isArray(patch.allowedOrigins)) cfg.allowedOrigins = patch.allowedOrigins;
|
|
113
112
|
if (patch.pro && typeof patch.pro === 'object') {
|
|
114
113
|
if (typeof patch.pro.entitlementToken === 'string') cfg.pro.entitlementToken = patch.pro.entitlementToken;
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// client can't raise the cap or reset its own trial.
|
|
114
|
+
// The free allowance (freegate.FREE_TOTAL_CAP) and its `used` count are not
|
|
115
|
+
// part of the editable patch.
|
|
118
116
|
}
|
|
119
117
|
// Local dictation toggle. The MODEL is switched via POST /stt/models (like the
|
|
120
118
|
// NER manager), not patched here.
|
|
@@ -1,25 +1,15 @@
|
|
|
1
|
-
// Online entitlement re-validation
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// by re-polling the license worker; the gateway didn't, so a refund left unlimited
|
|
8
|
-
// redaction open for the rest of the token's life.
|
|
9
|
-
//
|
|
10
|
-
// So we also re-check ONLINE on an interval: poll the worker's /entitlement for the
|
|
11
|
-
// token's install_id and either
|
|
12
|
-
// • REFRESH the stored token (still entitled) — so it never lapses while paid, and
|
|
13
|
-
// • CLEAR it (worker says valid:false → refunded/cancelled/revoked) — dropping the
|
|
14
|
-
// gateway to Free immediately instead of riding the offline exp.
|
|
15
|
-
// Network/worker errors NEVER revoke (fail-open for paying users); the offline `exp`
|
|
16
|
-
// still bounds the worst case. Bounds post-refund Pro to <= CHECK_INTERVAL_MS.
|
|
1
|
+
// Online entitlement re-validation. The gateway stores one offline-signed
|
|
2
|
+
// entitlement token and verifies it offline (entitlement.js). On an interval it also
|
|
3
|
+
// re-checks online: it polls the worker's /entitlement for the token's install_id and
|
|
4
|
+
// either refreshes the stored token (still entitled) or clears it (worker reports
|
|
5
|
+
// valid:false), dropping the gateway to Free. Network/worker errors never revoke
|
|
6
|
+
// (fail-open); the offline `exp` still bounds the token's lifetime.
|
|
17
7
|
|
|
18
8
|
import { persistConfig, configPath } from './configstore.js';
|
|
19
9
|
|
|
20
10
|
// Same worker the extension/bridge use. Overridable for self-hosted/test.
|
|
21
11
|
const API_BASE = (process.env.CHATPANEL_API_BASE || 'https://api.chatpanel.net').replace(/\/+$/, '');
|
|
22
|
-
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h —
|
|
12
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h — online re-check interval
|
|
23
13
|
const FIRST_CHECK_DELAY_MS = 30 * 1000; // let the server settle before first poll
|
|
24
14
|
const MIN_RECHECK_MS = 2 * 60 * 1000; // throttle on-demand (/status) re-checks
|
|
25
15
|
|
|
@@ -63,7 +53,7 @@ async function revalidate(cfg) {
|
|
|
63
53
|
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
64
54
|
}
|
|
65
55
|
} else if (data && data.valid === false) {
|
|
66
|
-
//
|
|
56
|
+
// Worker reports the token is no longer valid → clear it and drop to Free.
|
|
67
57
|
cfg.pro.entitlementToken = '';
|
|
68
58
|
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
69
59
|
console.log('[gateway] entitlement no longer valid — Pro deactivated (refund/revoke/seat lost).');
|
package/src/entitlement.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
// Offline
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// The license server (Cloudflare Worker) signs a compact entitlement token with
|
|
5
|
-
// an ECDSA P-256 private key that lives ONLY there. The bridge ships the matching
|
|
6
|
-
// PUBLIC key and verifies the signature locally — no network, no secret. A forked
|
|
7
|
-
// client or a raw `curl` to the bridge can't forge entitlement without the
|
|
8
|
-
// private key, so this is a real cryptographic gate, not a UI check.
|
|
1
|
+
// Offline entitlement verification. The license server signs an ECDSA P-256 token;
|
|
2
|
+
// the gateway ships the matching public key and verifies it locally. Gates paid
|
|
3
|
+
// features such as custom "bring your own CLI" agents.
|
|
9
4
|
//
|
|
10
5
|
// Token format (identical to the extension's, extension/js/license.js):
|
|
11
6
|
// token = base64url(JSON payload) + "." + base64url(raw ECDSA signature)
|
package/src/freegate.js
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
|
-
// Free vs Pro for the gateway runtime
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// thing, then you buy. Pro (a valid ChatPanel entitlement token — the same
|
|
6
|
-
// offline-signed token the extension and bridge use) unlocks UNLIMITED redaction.
|
|
7
|
-
// The cryptographic check (entitlement.js) means a forked UI can't unlock it —
|
|
8
|
-
// only the configured/paid token does.
|
|
9
|
-
//
|
|
10
|
-
// The cap is LIFETIME (overall), not per-day, and it is NOT user-editable — so a
|
|
11
|
-
// free user gets exactly FREE_TOTAL_CAP genuine redactions, full stop. The count
|
|
12
|
-
// persists in cfg.pro.free.used (written via configstore.persistConfig), so it
|
|
13
|
-
// survives restarts.
|
|
1
|
+
// Free vs Pro for the gateway runtime. Without an entitlement token, full-tier
|
|
2
|
+
// redaction is available for a fixed lifetime number of redactions (FREE_TOTAL_CAP);
|
|
3
|
+
// a valid ChatPanel entitlement token unlocks unlimited redaction. The count is
|
|
4
|
+
// stored in cfg.pro.free.used (persisted via configstore.persistConfig).
|
|
14
5
|
|
|
15
6
|
import { isProEntitled } from './entitlement.js';
|
|
16
7
|
|
|
17
|
-
// The lifetime free allowance.
|
|
8
|
+
// The lifetime free allowance.
|
|
18
9
|
export const FREE_TOTAL_CAP = 100;
|
|
19
10
|
|
|
20
11
|
const proCache = { token: null, val: false };
|
|
@@ -34,10 +25,10 @@ export async function resolvePro(token) {
|
|
|
34
25
|
return val;
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
// May this request still redact? Pro = always
|
|
38
|
-
// cap is reached
|
|
39
|
-
//
|
|
40
|
-
//
|
|
28
|
+
// May this request still redact? Pro = always; Free = allowed until the lifetime
|
|
29
|
+
// cap is reached. This only CHECKS — consume() advances the count after a
|
|
30
|
+
// redaction actually happens, so requests with nothing to redact don't consume
|
|
31
|
+
// allowance.
|
|
41
32
|
export function checkQuota(cfg, isPro) {
|
|
42
33
|
if (isPro) return { allowed: true, remaining: Infinity, isPro: true };
|
|
43
34
|
const used = Number(cfg.pro?.free?.used) || 0;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Per-install bearer token for the gateway's ADMIN routes (M2), mirroring the
|
|
2
|
+
// bridge's model. The gateway proxies model traffic for any local client (the /v1/*
|
|
3
|
+
// data plane stays open, no auth — that's the product), but its ADMIN surface
|
|
4
|
+
// (reconfigure via POST /config, read /logs) must not be reachable by every local
|
|
5
|
+
// process or by a drive-by localhost web page.
|
|
6
|
+
//
|
|
7
|
+
// Two ways to authenticate an admin call:
|
|
8
|
+
// • the ChatPanel extension — recognised by its chrome-/moz-extension:// Origin
|
|
9
|
+
// (a web page can't forge that; browsers set it honestly). The extension can't
|
|
10
|
+
// read a local file, so Origin is how IT authenticates.
|
|
11
|
+
// • a token — this 32-byte secret at ~/.chatpanel/gateway-token (0600), for a
|
|
12
|
+
// non-browser admin client (a setup script/CLI). A random local process doesn't
|
|
13
|
+
// have it.
|
|
14
|
+
// Residual (same as the bridge): a MALICIOUS local process could forge the
|
|
15
|
+
// extension Origin. A local attacker at that privilege level already owns much;
|
|
16
|
+
// this still blocks web drive-by and benign no-Origin processes from the admin API.
|
|
17
|
+
|
|
18
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
20
|
+
import { join, dirname } from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
|
|
23
|
+
const TOKEN_PATH = process.env.CHATPANEL_GATEWAY_TOKEN_PATH || join(os.homedir(), '.chatpanel', 'gateway-token');
|
|
24
|
+
let TOKEN = '';
|
|
25
|
+
|
|
26
|
+
// Load-or-create the token. Best-effort: token auth is hardening, never fail startup.
|
|
27
|
+
export function ensureGatewayToken(path = TOKEN_PATH) {
|
|
28
|
+
try {
|
|
29
|
+
if (existsSync(path)) TOKEN = readFileSync(path, 'utf8').trim();
|
|
30
|
+
if (!TOKEN) {
|
|
31
|
+
TOKEN = randomBytes(32).toString('hex');
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
33
|
+
writeFileSync(path, TOKEN, { mode: 0o600 });
|
|
34
|
+
try { chmodSync(path, 0o600); } catch { /* non-POSIX */ }
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.warn(`[gateway] could not initialise admin token: ${e?.message || e}`);
|
|
38
|
+
}
|
|
39
|
+
return TOKEN;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isExtensionOrigin(origin) {
|
|
43
|
+
return typeof origin === 'string' && (/^chrome-extension:\/\//.test(origin) || /^moz-extension:\/\//.test(origin));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tokenMatches(headers) {
|
|
47
|
+
if (!TOKEN) return false;
|
|
48
|
+
const h = String(headers?.authorization || '');
|
|
49
|
+
const provided = (h.startsWith('Bearer ') ? h.slice(7) : String(headers?.['x-chatpanel-token'] || '')).trim();
|
|
50
|
+
if (!provided) return false;
|
|
51
|
+
const a = Buffer.from(provided);
|
|
52
|
+
const b = Buffer.from(TOKEN);
|
|
53
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// True when a request may reach an ADMIN route: the extension (by Origin) or a
|
|
57
|
+
// token-bearing client.
|
|
58
|
+
export function isAdminAuthorized(req) {
|
|
59
|
+
return isExtensionOrigin(req.headers?.origin) || tokenMatches(req.headers);
|
|
60
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_README": "SHA-256 hashes of curated model weight files, keyed by modelId then by path relative to the model's cache dir (~/.chatpanel/models/<org>/<name>/). Generated on a TRUSTED host by tools/gen-model-hashes.mjs — do NOT hand-edit. Empty = verification is warn-and-allow (no protection yet); populate to enable fail-closed integrity checking. Keys starting with '_' are ignored by the loader."
|
|
3
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Model-weight integrity verification (H3).
|
|
2
|
+
//
|
|
3
|
+
// The gateway downloads ONNX model weights (NER / Whisper / Parakeet) from the
|
|
4
|
+
// dl.chatpanel.net mirror or Hugging Face and loads them straight into an ONNX
|
|
5
|
+
// runtime. A compromised mirror, an HF repo takeover, or a MITM on a downgraded
|
|
6
|
+
// connection could therefore feed a malicious model into native code. This module
|
|
7
|
+
// verifies a downloaded model's weight files against committed SHA-256 hashes.
|
|
8
|
+
//
|
|
9
|
+
// Design (deliberately non-breaking):
|
|
10
|
+
// • Hashes live in the committed `model-hashes.json` — a { modelId: { relPath:
|
|
11
|
+
// sha256 } } map generated on a TRUSTED host by `tools/gen-model-hashes.mjs`.
|
|
12
|
+
// • FAIL-CLOSED only on a genuine mismatch: a listed file whose hash differs is
|
|
13
|
+
// deleted and the load is refused (throws). That's the security event.
|
|
14
|
+
// • WARN-AND-ALLOW everywhere else: a model not in the manifest, or a listed file
|
|
15
|
+
// not yet on disk, logs loudly and proceeds — so an empty/partial manifest never
|
|
16
|
+
// bricks model loading, and shipping the mechanism before the hashes are
|
|
17
|
+
// populated is safe. (Populate on a trusted machine to switch protection on.)
|
|
18
|
+
// • Verified on the DOWNLOAD path (when weights first arrive from the network —
|
|
19
|
+
// the supply-chain moment), not on every offline load (avoids re-hashing 100s of
|
|
20
|
+
// MB each boot).
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { readFileSync, existsSync, rmSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const MANIFEST_PATH = fileURLToPath(new URL('./model-hashes.json', import.meta.url));
|
|
28
|
+
let _manifest = null;
|
|
29
|
+
|
|
30
|
+
// { modelId: { 'onnx/model_quantized.onnx': '<sha256>' , … } }. Keys starting with
|
|
31
|
+
// '_' are metadata (schema note) and ignored. Missing/invalid file → empty map
|
|
32
|
+
// (verification simply becomes warn-and-allow for everything).
|
|
33
|
+
export function loadHashManifest(path = MANIFEST_PATH) {
|
|
34
|
+
if (_manifest && path === MANIFEST_PATH) return _manifest;
|
|
35
|
+
let parsed = {};
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
38
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
39
|
+
if (!k.startsWith('_') && v && typeof v === 'object') parsed[k] = v;
|
|
40
|
+
}
|
|
41
|
+
} catch { parsed = {}; }
|
|
42
|
+
if (path === MANIFEST_PATH) _manifest = parsed;
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sha256File(path) {
|
|
47
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Verify a model's weight files under `modelsDir`. Returns a result object; THROWS
|
|
51
|
+
// only on a real hash mismatch (fail-closed). `modelsDir` is the model cache root
|
|
52
|
+
// (e.g. ~/.chatpanel/models); the per-model dir is modelsDir/<org>/<name>.
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} modelId
|
|
55
|
+
* @param {{ modelsDir?: string, log?: (msg: string) => void, manifest?: Record<string, Record<string, string>> }} [opts]
|
|
56
|
+
*/
|
|
57
|
+
export function verifyModelWeights(modelId, { modelsDir, log = () => {}, manifest = loadHashManifest() } = {}) {
|
|
58
|
+
const expected = manifest[modelId];
|
|
59
|
+
if (!expected || Object.keys(expected).length === 0) {
|
|
60
|
+
log(`[integrity] no recorded hashes for ${modelId} — skipping verification `
|
|
61
|
+
+ `(run tools/gen-model-hashes.mjs on a trusted host to enable)`);
|
|
62
|
+
return { verified: false, reason: 'unlisted', checked: 0 };
|
|
63
|
+
}
|
|
64
|
+
const dir = join(modelsDir, ...modelId.split('/'));
|
|
65
|
+
let checked = 0;
|
|
66
|
+
for (const [rel, wantSha] of Object.entries(expected)) {
|
|
67
|
+
const p = join(dir, ...rel.split('/'));
|
|
68
|
+
if (!existsSync(p)) { log(`[integrity] ${modelId}: listed file ${rel} not on disk — skipping`); continue; }
|
|
69
|
+
const gotSha = sha256File(p);
|
|
70
|
+
if (gotSha !== wantSha) {
|
|
71
|
+
try { rmSync(p); } catch { /* best effort */ }
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[integrity] ${modelId}/${rel} SHA-256 mismatch `
|
|
74
|
+
+ `(expected ${wantSha.slice(0, 12)}…, got ${gotSha.slice(0, 12)}…) — `
|
|
75
|
+
+ `deleted the file and refusing to load a tampered model`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
checked++;
|
|
79
|
+
}
|
|
80
|
+
if (checked) log(`[integrity] ${modelId}: ${checked} weight file(s) verified ✓`);
|
|
81
|
+
return { verified: checked > 0, reason: checked ? 'ok' : 'no-files-present', checked };
|
|
82
|
+
}
|
package/src/ner-engine.js
CHANGED
|
@@ -19,6 +19,7 @@ import { join } from 'node:path';
|
|
|
19
19
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
20
20
|
import { isKnownModel } from './models.js';
|
|
21
21
|
import { isLoopbackHost, isPrivateHost, isMetadataHost } from '@chatpanel/pii';
|
|
22
|
+
import { verifyModelWeights } from './model-integrity.js';
|
|
22
23
|
|
|
23
24
|
const DEFAULT_MODEL = 'Xenova/bert-base-NER';
|
|
24
25
|
const DEFAULT_MODEL_HOST = 'https://dl.chatpanel.net/models/';
|
|
@@ -256,6 +257,20 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
|
|
|
256
257
|
lib.env.allowRemoteModels = true;
|
|
257
258
|
pipe = await lib.pipeline('token-classification', modelId, { dtype: 'q8', progress_callback });
|
|
258
259
|
}
|
|
260
|
+
// H3: verify freshly-downloaded weights against committed hashes before we trust
|
|
261
|
+
// this pipeline. On a mismatch, dispose it (never use its outputs) and let the
|
|
262
|
+
// throw fall through to the catch (keeps the previous model / deterministic-only).
|
|
263
|
+
// NOTE: transformers downloads+loads in one call, so this runs POST-load — it
|
|
264
|
+
// prevents USING and PERSISTING a tampered model, not a hypothetical ORT
|
|
265
|
+
// parse-time bug. Warn-and-allow when the model isn't in the hash manifest.
|
|
266
|
+
if (!haveLocal) {
|
|
267
|
+
try {
|
|
268
|
+
verifyModelWeights(modelId, { modelsDir: modelRoot(), log });
|
|
269
|
+
} catch (e) {
|
|
270
|
+
try { await pipe.dispose?.(); } catch { /* ignore */ }
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
259
274
|
// Swap in the new pipeline, dispose the old one (free its WASM/native session).
|
|
260
275
|
_pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
|
|
261
276
|
if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
|
package/src/redact.js
CHANGED
|
@@ -14,10 +14,10 @@ import * as engine from './ner-engine.js';
|
|
|
14
14
|
// tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
|
|
15
15
|
// text to harvest names/orgs, then redact every segment against that entity set.
|
|
16
16
|
//
|
|
17
|
-
// Free
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// Free use is bounded by a request quota (freegate.js), not by downgrading
|
|
18
|
+
// quality — free requests get the full tier (names/orgs via NER) within their
|
|
19
|
+
// allowance. The custom dictionary is capped for free: gatedDictionary limits it
|
|
20
|
+
// to FREE_DICT_LIMIT via the shared chatpanel-pii gate.
|
|
21
21
|
export async function redactSegments(segments, redactionCfg, { signal, isPro = true } = {}) {
|
|
22
22
|
const vault = createVault();
|
|
23
23
|
|
|
@@ -38,7 +38,7 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
|
|
|
38
38
|
const texts = segments.map((s) => s.get()).filter((t) => typeof t === 'string' && t);
|
|
39
39
|
if (texts.length === 0) return { vault, count: 0, sanitized };
|
|
40
40
|
|
|
41
|
-
// Use the configured tier as-is (no free downgrade — the quota
|
|
41
|
+
// Use the configured tier as-is (no free downgrade — the quota bounds free use),
|
|
42
42
|
// but keep the dictionary capped for free via the shared chatpanel-pii gate.
|
|
43
43
|
const tier = redactionCfg.tier === 'full' ? 'full' : 'basic';
|
|
44
44
|
const dictionary = gatedDictionary(redactionCfg, isPro);
|
package/src/router.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// models: [..], models this destination serves (for /v1/models)
|
|
12
12
|
// }
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { secureFetch } from './secure-fetch.js';
|
|
15
15
|
//
|
|
16
16
|
// /v1/models aggregates every destination's models so clients can discover them.
|
|
17
17
|
|
|
@@ -89,8 +89,8 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
|
89
89
|
if (d.protocol === 'anthropic') { headers['x-api-key'] = d.apiKey; headers['anthropic-version'] = '2023-06-01'; }
|
|
90
90
|
else headers.authorization = `Bearer ${d.apiKey}`;
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
const res = await
|
|
92
|
+
// secureFetch: SSRF guard (scheme/host + resolved-IP); a blocked dest throws → skipped via the catch.
|
|
93
|
+
const res = await secureFetch(`${d.baseUrl.replace(/\/$/, '')}/models`, { headers, signal: ctrl.signal });
|
|
94
94
|
if (!res.ok) return;
|
|
95
95
|
const j = await res.json();
|
|
96
96
|
const list = Array.isArray(j?.data) ? j.data : (Array.isArray(j?.models) ? j.models : []);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Node-side SSRF-hardened fetch (M1). The shared @chatpanel/pii/net.js classifier is
|
|
2
|
+
// browser-safe and can only match on the URL's HOSTNAME string — so a public name
|
|
3
|
+
// whose A/AAAA record resolves to a private/metadata IP (DNS-rebinding at the fetch
|
|
4
|
+
// layer) slips past it. Node CAN resolve DNS, so here we additionally validate every
|
|
5
|
+
// RESOLVED address against the same policy, and revalidate on each redirect hop.
|
|
6
|
+
//
|
|
7
|
+
// Policy defaults to the ENDPOINT context (loopback + LAN allowed — Ollama / a LAN
|
|
8
|
+
// model box are legitimate gateway upstreams — but cloud metadata never). So in
|
|
9
|
+
// practice the DNS check blocks a hostname that resolves to cloud metadata; the
|
|
10
|
+
// redirect revalidation blocks a public upstream that 3xx-redirects to a blocked host.
|
|
11
|
+
//
|
|
12
|
+
// Residual (documented): we validate the resolved IPs but do NOT pin the socket to
|
|
13
|
+
// them, so a sub-second TOCTOU window between lookup and connect remains. Closing it
|
|
14
|
+
// fully needs a custom undici dispatcher; the resolved-IP check already removes the
|
|
15
|
+
// easy DNS-rebinding cases, which is the point of M1.
|
|
16
|
+
|
|
17
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
18
|
+
import { assertEndpointUrl, isBlockedHost } from '@chatpanel/pii';
|
|
19
|
+
|
|
20
|
+
const ENDPOINT_POLICY = { allowLoopback: true, allowPrivate: true };
|
|
21
|
+
const isLiteralIp = (h) => /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.includes(':');
|
|
22
|
+
|
|
23
|
+
// Reject if ANY resolved address is blocked under `policy`. A literal-IP host is
|
|
24
|
+
// already covered by the URL check, so skip DNS for it. A resolution failure is left
|
|
25
|
+
// for fetch() to surface (don't mask a real network error as a security error).
|
|
26
|
+
async function assertResolvedIps(hostname, policy, lookupFn) {
|
|
27
|
+
if (isLiteralIp(hostname)) return;
|
|
28
|
+
let addrs;
|
|
29
|
+
try { addrs = await lookupFn(hostname, { all: true }); } catch { return; }
|
|
30
|
+
for (const { address } of addrs) {
|
|
31
|
+
if (isBlockedHost(address, policy)) {
|
|
32
|
+
throw new Error(`refusing to reach ${hostname} — it resolves to a blocked address (${address})`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// secureFetch(url, { policy?, maxRedirects?, lookupFn?, fetchFn?, ...init })
|
|
38
|
+
// Validates the URL (scheme + host policy) AND its resolved IPs before each request,
|
|
39
|
+
// following redirects manually so every hop is re-checked. Returns the final Response.
|
|
40
|
+
// `...init` forwards any standard fetch options (method, headers, body, signal, …).
|
|
41
|
+
/**
|
|
42
|
+
* @param {string|URL} url
|
|
43
|
+
* @param {any} [opts]
|
|
44
|
+
*/
|
|
45
|
+
export async function secureFetch(url, {
|
|
46
|
+
policy = ENDPOINT_POLICY, maxRedirects = 5, lookupFn = dnsLookup, fetchFn = fetch, ...init
|
|
47
|
+
} = {}) {
|
|
48
|
+
let current = assertEndpointUrl(url, policy);
|
|
49
|
+
for (let hop = 0; ; hop++) {
|
|
50
|
+
await assertResolvedIps(current.hostname, policy, lookupFn);
|
|
51
|
+
const res = await fetchFn(current.toString(), { ...init, redirect: 'manual' });
|
|
52
|
+
const loc = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
|
53
|
+
if (!loc) return res;
|
|
54
|
+
if (hop >= maxRedirects) throw new Error('too many redirects');
|
|
55
|
+
current = assertEndpointUrl(new URL(loc, current).toString(), policy); // revalidate the hop
|
|
56
|
+
try { res.body?.cancel?.(); } catch { /* free the redirect body */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/server.js
CHANGED
|
@@ -23,6 +23,8 @@ import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.
|
|
|
23
23
|
import { redactSegments, segment } from './redact.js';
|
|
24
24
|
import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
|
|
25
25
|
import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote, assertEndpointUrl } from '@chatpanel/pii';
|
|
26
|
+
import { ensureGatewayToken, isAdminAuthorized } from './gateway-token.js';
|
|
27
|
+
import { secureFetch } from './secure-fetch.js';
|
|
26
28
|
import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
27
29
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
28
30
|
import { shaperFor } from './shape.js';
|
|
@@ -43,7 +45,7 @@ import * as openai from './openai.js';
|
|
|
43
45
|
import * as responses from './responses.js';
|
|
44
46
|
import * as anthropic from './anthropic.js';
|
|
45
47
|
|
|
46
|
-
export const VERSION = '0.6.
|
|
48
|
+
export const VERSION = '0.6.31';
|
|
47
49
|
|
|
48
50
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
49
51
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -255,8 +257,8 @@ async function probeNerHealth(cfg) {
|
|
|
255
257
|
const url = nerBaseUrl(cfg);
|
|
256
258
|
if (!url) return { configured: false, ok: false, url: null, model: null };
|
|
257
259
|
try {
|
|
258
|
-
|
|
259
|
-
const r = await
|
|
260
|
+
// secureFetch: scheme + host policy AND resolved-IP validation (DNS-rebinding).
|
|
261
|
+
const r = await secureFetch(url.replace(/\/ner\/?$/, '') + '/health', { signal: AbortSignal.timeout(2000) });
|
|
260
262
|
if (!r.ok) return { configured: true, ok: false, url, model: null };
|
|
261
263
|
const j = await r.json().catch(() => ({}));
|
|
262
264
|
return { configured: true, ok: true, url, model: j.model || null };
|
|
@@ -307,8 +309,8 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
|
|
|
307
309
|
const { messages, system } = adapter.toTurn(body);
|
|
308
310
|
const token = readBridgeToken(cfg.bridge.token);
|
|
309
311
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
310
|
-
// Full tier for everyone here (the free allowance is enforced
|
|
311
|
-
//
|
|
312
|
+
// Full tier for everyone here (the free allowance is enforced in the main
|
|
313
|
+
// handler), but the custom dictionary stays capped for free.
|
|
312
314
|
const redactOpts = { tier: cfg.redaction.tier === 'full' ? 'full' : 'basic', dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
|
|
313
315
|
const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
|
|
314
316
|
const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
|
|
@@ -497,6 +499,26 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
497
499
|
if (req.headers.origin) setCors(res, req.headers.origin);
|
|
498
500
|
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
|
|
499
501
|
|
|
502
|
+
// Admin-token handshake. The extension authenticates admin routes by its
|
|
503
|
+
// chrome-extension:// Origin, but Chrome OMITS Origin on GET requests to a host the
|
|
504
|
+
// extension has permission for — so config READS (GET /config) would fail. A POST
|
|
505
|
+
// still carries the Origin, so the extension POSTs here (authorized by Origin) to get
|
|
506
|
+
// the token, then sends it as `Authorization: Bearer` on the GET admin routes. A
|
|
507
|
+
// drive-by web page can't reach this: its Origin isn't chrome-extension:// (Origin
|
|
508
|
+
// check) and it has no token. Additive route — old extensions ignore it.
|
|
509
|
+
if (pathname === '/admin/token' && req.method === 'POST') {
|
|
510
|
+
if (!isAdminAuthorized(req)) return sendJson(res, 403, { error: 'admin: extension origin or gateway token required' });
|
|
511
|
+
return sendJson(res, 200, { token: ensureGatewayToken() });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// M2: ADMIN routes reconfigure the gateway (POST /config) or expose its in-memory
|
|
515
|
+
// logs (GET /logs). Unlike the /v1 data plane (open to any local client — the
|
|
516
|
+
// product), these must not be reachable by a no-Origin local process or a drive-by
|
|
517
|
+
// localhost web page. Require the extension Origin or the gateway token.
|
|
518
|
+
if ((pathname === '/config' || pathname === '/logs') && !isAdminAuthorized(req)) {
|
|
519
|
+
return sendJson(res, 403, { error: 'admin route: extension origin or gateway token required' });
|
|
520
|
+
}
|
|
521
|
+
|
|
500
522
|
if (req.method === 'GET' && pathname === '/health') {
|
|
501
523
|
// `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
|
|
502
524
|
// to auto-detect local dictation. `enabled` reflects config; the model only
|
|
@@ -626,8 +648,8 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
626
648
|
if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
|
|
627
649
|
try {
|
|
628
650
|
const body = await readBody(req, cfg.maxBodyBytes);
|
|
629
|
-
|
|
630
|
-
const r = await
|
|
651
|
+
// secureFetch: scheme/host policy + resolved-IP check before POSTing raw text to the detector.
|
|
652
|
+
const r = await secureFetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
|
|
631
653
|
const text = await r.text();
|
|
632
654
|
res.writeHead(r.status, { 'content-type': 'application/json' });
|
|
633
655
|
return res.end(text);
|
|
@@ -898,9 +920,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
898
920
|
body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
|
|
899
921
|
narrowedTools = before - body.tools.length;
|
|
900
922
|
}
|
|
901
|
-
// Free
|
|
902
|
-
//
|
|
903
|
-
//
|
|
923
|
+
// Free users get full-tier redaction within a fixed lifetime allowance —
|
|
924
|
+
// checked here, consumed below once a redaction actually happens. Over the
|
|
925
|
+
// cap returns 402.
|
|
904
926
|
isPro = await resolvePro(cfg.pro?.entitlementToken);
|
|
905
927
|
const allow = checkQuota(cfg, isPro);
|
|
906
928
|
if (!allow.allowed) {
|
|
@@ -913,16 +935,16 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
913
935
|
const ac = new AbortController();
|
|
914
936
|
req.on('close', () => ac.abort());
|
|
915
937
|
const rd0 = trace ? trace.clock() : 0;
|
|
916
|
-
// Redact at the configured tier for everyone (free users get
|
|
917
|
-
//
|
|
918
|
-
//
|
|
938
|
+
// Redact at the configured tier for everyone (free users get name/org
|
|
939
|
+
// redaction within their allowance); the custom dictionary stays capped for
|
|
940
|
+
// free (isPro decides that inside).
|
|
919
941
|
const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
|
|
920
942
|
if (trace) trace.lap('redact', rd0);
|
|
921
943
|
vault = v;
|
|
922
944
|
redactedCount = count;
|
|
923
945
|
sanitizedCount = sanitized || 0;
|
|
924
|
-
//
|
|
925
|
-
// then persist
|
|
946
|
+
// Consume one lifetime free credit only when we actually redacted
|
|
947
|
+
// something, then persist. (No-op / no write for Pro.)
|
|
926
948
|
if (!isPro && count > 0) {
|
|
927
949
|
consume(cfg, isPro);
|
|
928
950
|
try { persistConfig(cfg, configPath()); } catch { /* best effort — usage is advisory */ }
|
|
@@ -964,11 +986,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
964
986
|
|
|
965
987
|
export function start(cfg = loadConfig()) {
|
|
966
988
|
installTimestampedConsole(); // every gateway log line gets a clock — before anything logs
|
|
989
|
+
ensureGatewayToken(); // M2: load/create the admin-route token (best-effort)
|
|
967
990
|
const server = createGateway(cfg);
|
|
968
991
|
const ner = startNer(cfg); // may mutate cfg.redaction when it comes up
|
|
969
|
-
// Re-validate the stored
|
|
970
|
-
//
|
|
971
|
-
//
|
|
992
|
+
// Re-validate the stored entitlement online on an interval; clears it and drops
|
|
993
|
+
// the gateway to Free when the worker reports it invalid (see
|
|
994
|
+
// entitlement-refresh.js).
|
|
972
995
|
const entitlement = startEntitlementRefresh(cfg);
|
|
973
996
|
// Fail LOUD on a port clash instead of crashing with a raw stack trace. We bind a
|
|
974
997
|
// FIXED port (4320) so the extension / install.sh / OpenCode can always find us; if
|
|
@@ -989,6 +1012,12 @@ export function start(cfg = loadConfig()) {
|
|
|
989
1012
|
console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
|
|
990
1013
|
console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
|
|
991
1014
|
? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
|
|
1015
|
+
// M7: a non-loopback bind exposes the gateway on the LAN, where the per-request
|
|
1016
|
+
// loopback Host check is trivially satisfied by a spoofed `Host: 127.0.0.1`. The
|
|
1017
|
+
// /v1 data plane forwards with the client's own key, but make the exposure LOUD.
|
|
1018
|
+
if (!isLoopbackHost(cfg.host)) {
|
|
1019
|
+
console.error(`⚠ SECURITY: gateway bound to NON-LOOPBACK host ${cfg.host}. It is reachable off-machine, and the loopback Host-header check is spoofable from the LAN. Admin routes still need the token/extension, but prefer binding 127.0.0.1 unless you intend LAN exposure on a trusted network.`);
|
|
1020
|
+
}
|
|
992
1021
|
});
|
|
993
1022
|
// If the user handed off a backup key, refresh the warm store from the latest
|
|
994
1023
|
// daily backup in the background — so the gateway stays current even when the
|
package/src/stt-engine.js
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { existsSync, readdirSync } from 'node:fs';
|
|
21
21
|
import { randomUUID } from 'node:crypto';
|
|
22
22
|
import { ensureLib, modelRoot } from './ner-engine.js';
|
|
23
|
+
import { verifyModelWeights } from './model-integrity.js';
|
|
23
24
|
import { DEFAULT_STT_MODEL, isEnglishOnly, sttModelDtype, isKnownSttModel, sttModelEngine } from './stt-models.js';
|
|
24
25
|
import * as parakeet from './parakeet-engine.js';
|
|
25
26
|
import * as diarize from './diarize-engine.js';
|
|
@@ -151,6 +152,16 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype:
|
|
|
151
152
|
lib.env.allowRemoteModels = true;
|
|
152
153
|
pipe = await lib.pipeline('automatic-speech-recognition', modelId, { dtype, progress_callback });
|
|
153
154
|
}
|
|
155
|
+
// H3: verify freshly-downloaded weights (fail-closed on a real mismatch;
|
|
156
|
+
// warn-and-allow when unlisted). Post-load — see model-integrity.js note.
|
|
157
|
+
if (!haveLocal) {
|
|
158
|
+
try {
|
|
159
|
+
verifyModelWeights(modelId, { modelsDir: modelRoot(), log });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
try { await pipe.dispose?.(); } catch { /* ignore */ }
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
154
165
|
_pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null; _dtype = dtype;
|
|
155
166
|
if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
|
|
156
167
|
log(`[stt] ready — model ${modelId} @ ${dtype} (in-process, offline) — local dictation active`);
|