@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.29",
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.12",
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
- // Monetization: the gateway is free to try, paid to rely on. Free = full-tier
38
- // redaction (the real thing NER names/orgs + dictionary) for a FIXED LIFETIME
39
- // allowance (freegate.FREE_TOTAL_CAP redactions), then it stops. Paste a
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: {
@@ -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 }) read-only; the cap
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
- // NOTE: the free trial is a FIXED lifetime cap (freegate.FREE_TOTAL_CAP) and
116
- // its `used` count is server-authoritative — neither is editable here, so a
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 closes the refund/revoke abuse window.
2
- //
3
- // The gateway stores ONE offline-signed entitlement token (pushed once from the
4
- // extension via POST /config). Verified purely offline (entitlement.js), that token
5
- // keeps unlocking Pro until its `exp` — up to 7 days EVEN AFTER the subscription
6
- // is refunded, cancelled, or the seat is revoked. The extension/bridge avoid this
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 — the autonomous post-refund Pro window
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
- // Refunded / cancelled / seat revoked drop Pro NOW (don't wait out the exp).
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).');
@@ -1,11 +1,6 @@
1
- // Offline Pro/Team entitlement verification the HARD gate for paid features
2
- // (e.g. custom "bring your own CLI" agents).
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 the "taste" gate.
2
- //
3
- // Free (no entitlement token): full-tier redaction works, but only for a fixed
4
- // LIFETIME number of redactions (FREE_TOTAL_CAP) a real trial of the genuine
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. Fixed — deliberately NOT configurable.
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. Free = allowed until the lifetime
38
- // cap is reached, then refused so the client gets a clear upsell. This only
39
- // CHECKS the count is advanced by consume() AFTER a redaction actually happens,
40
- // so requests with nothing to redact don't burn the allowance.
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 vs Pro on the gateway: the free trial is limited by a REQUEST QUOTA
18
- // (freegate.js), not by downgrading quality — so free users get the REAL tier
19
- // (names/orgs via NER) within their allowance. The custom dictionary, though, is
20
- // still a Pro power feature: gatedDictionary caps it to FREE_DICT_LIMIT for free.
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 is the free gate),
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 { assertEndpointUrl } from '@chatpanel/pii';
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
- const modelsUrl = assertEndpointUrl(`${d.baseUrl.replace(/\/$/, '')}/models`).toString(); // SSRF guard (skips a blocked dest via the catch)
93
- const res = await fetch(modelsUrl, { headers, signal: ctrl.signal });
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.29';
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
- const healthUrl = assertEndpointUrl(url.replace(/\/ner\/?$/, '') + '/health').toString();
259
- const r = await fetch(healthUrl, { signal: AbortSignal.timeout(2000) });
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 by the quota gate
311
- // in the main handler), but the custom dictionary stays capped for free.
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
- assertEndpointUrl(url); // block metadata/non-http(s) before POSTing raw text to the detector
630
- const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
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/Pro gate: free users get the REAL thing (full-tier redaction), but
902
- // only for a fixed lifetime allowance — checked here, consumed below once a
903
- // redaction actually happens. Over the cap 402 upsell.
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 genuine
917
- // name/org redaction within their allowance, not a downgraded preview);
918
- // the custom dictionary stays capped for free (isPro decides that inside).
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
- // Burn one lifetime free credit only when we actually redacted something,
925
- // then persist so the count survives a restart. (No-op / no write for Pro.)
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 Pro entitlement online on an interval, so a refunded /
970
- // revoked subscription drops the gateway to Free instead of riding the offline
971
- // token to its exp (see entitlement-refresh.js).
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`);