Skip to content

Security: etopple/tracked-dialer

Security

docs/SECURITY.md

Security model

What holds what

Secret Lives in Never in
GlassHive API key Worker secret extension, repo, browser
OIT API key Worker secret extension, repo, browser
Rep identity Entra access token, verified per request request body

The extension holds no shared secret. Its only credential is the signed Entra token belonging to the rep using it.

Authorisation layers

A call is only written to GlassHive if all three agree:

  1. Entra issues a token. The app registration has Assignment required enabled, so only members of the dialer access group can obtain one.
  2. The Worker validates it — RS256 signature against tenant JWKS, plus tid, iss, aud, exp/nbf, and the Dialer.Call scope. App-only tokens are refused; a leaked client credential cannot dial.
  3. The rep exists in D1 and is active. This maps them to a GlassHive user id and an OIT extension.

Rep email comes from the verified token. A rep cannot log activity as a colleague, which the previous shared-token design allowed.

The Entra object id is pinned to the rep row on first sign-in. If a mailbox is later reassigned, the oid no longer matches and the dial is refused rather than attributed to the wrong GlassHive user.

Attack surface

Five routes, two of them authenticated and state-changing. This table is the complete list — if a route exists in index.js and is not here, one of the two is wrong:

Route Auth Effect
GET /health none liveness only, discloses nothing
GET /ext/update.xml none extension version manifest; public by design
GET /api/me Entra reads the caller's own provisioning state
POST /api/dial-intents Entra creates one GlassHive activity
POST /api/dial-intents/:id/notes Entra writes notes on the caller's own call

Both authenticated routes require an active reps row, not merely a valid token: a deactivated rep holds a working Entra token until it expires, and must not be able to keep writing into GlassHive through their old calls.

There are no diagnostic routes. /api/diag/oit, /api/diag/enrich and /api/diag/originate existed to bring up the OIT integration and are gone. They were gated on a single static header token, and behind it /api/diag/originate placed real calls to an arbitrary number from an arbitrary extension and /api/diag/enrich returned transcript text from real customer calls. Bring-up is over; the equivalent probes are in scripts/oit-*.ps1 and run against OIT directly under an operator's own credentials. Do not reintroduce them into the Worker.

There are no inbound webhooks. Enrichment is an outbound poll triggered by the dial. This is the single largest change from the pilot design, which exposed unauthenticated /webhooks/oit and /webhooks/plaud endpoints that would have accepted arbitrary text into any prospect's CRM record from any source on the internet.

There is no admin page. Vendor credentials are set with wrangler secret put; rep provisioning is a D1 statement run by an operator. The pilot's password-protected settings page — which accepted and stored every vendor key over whatever transport the host happened to have — is gone.

Session lifetime

The extension is registered as a public client ("Mobile and desktop applications"), not a SPA. Entra caps refresh tokens issued to the SPA platform at 24 hours and single use, which on a tool reps use all day means a forced interactive sign-in every morning, surfacing as "the dialer stopped logging calls". CORS is not a reason to prefer SPA here: the extension holds host_permissions for login.microsoftonline.com, so its token request is not a CORS request at all.

A failed silent refresh only clears the session when Entra actually refuses the credential (invalid_grant and friends, or a non-429 4xx). A network failure or a 5xx keeps the refresh token — throwing away a working credential over a blip costs the rep a sign-in for a problem that fixes itself.

Request hardening

  • Bodies capped at 8 KB, checked before and after read, returning 400 — not 500, which the extension's offline queue would treat as retryable and replay forever.
  • 30 dials per rep per minute; 20 note saves per rep per minute.
  • destination normalised to E.164 and rejected if it is not a valid NANP number; glassHiveContactId must be a positive integer.
  • Client-supplied occurredAt (used when replaying a queued offline dial) is clamped: no future-dating beyond clock skew, no backdating beyond 24h.
  • Internal errors return an opaque 500. Upstream vendor error bodies are logged, never echoed — they can carry key fragments and internal URLs.

Enrichment integrity

Call records are matched to a dial intent by the UUID we minted, scoped to the rep's own extension and a time window around the click. Forging enrichment requires guessing a server-generated UUID, not knowing a colleague's email.

oit_call_id is UNIQUE, so a redelivered queue message or repeated CDR cannot double-write. If two calls fit a window, the nearest in time wins; if none fit, nothing is attached. An unattached call beats a call attached to the wrong prospect.

Audit

Every dial, match, skip and abandonment is appended to audit_log with the authenticated principal and a timestamp, so a disputed CRM activity can be traced back to a person and a moment.

Data handling

Call recordings are transcribed with Workers AI, inside Cloudflare. Audio is not sent to a third-party transcription vendor. Transcripts are stored in D1 and written into the GlassHive activity.

Residual risks

  • Extension distribution. Load-unpacked installs can be modified locally by the rep. The manifest key pins the extension id, but managed deployment via Chrome/Edge policy is what actually enforces integrity. See ADMIN_SETUP.
  • OIT payload shape is unverified. See VENDOR_REQUESTS.md.
  • Transcription size cap. Recordings over 24 MB are linked but not transcribed. Long-call behaviour needs validating against a real recording.

There aren't any published security advisories