From 1b7bad168654690b0fa4db7d665950c614ae7611 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 16 Jun 2026 06:17:17 -0400 Subject: [PATCH] security: harden payments/hub auth + remove leaked ERPNext token from source Auth hardening: - payments: per-customer JWT authorization on dual-use /payments routes (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage) - hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion Leaked-credential cleanup (token already rotated in ERPNext): - de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py, apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects) - ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty) - de-hardcode legacy DB password -> env in import_items.py - gitignore legacy migration PII exports (tsv/json) Conversations/UI: - floating (un-docked) conversation panel; full-width mailbox - per-message real sender from email headers; unified scroll; header spacing - campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + apps/client/deploy.sh | 6 +- apps/client/src/api/payments.js | 13 +- apps/client/src/stores/customer.js | 35 +- apps/ops/.env.production | 6 + apps/ops/infra/nginx.conf | 2 +- apps/ops/src/api/campaigns.js | 8 + .../components/shared/ConversationPanel.vue | 117 +++++- .../ops/src/components/shared/MessageList.vue | 27 +- .../campaigns/components/ChannelToggle.vue | 29 ++ .../campaigns/components/SenderField.vue | 73 ++++ .../campaigns/pages/CampaignDetailPage.vue | 221 +++++++++- scripts/bulk_submit.py | 3 +- scripts/migration/import_items.py | 5 +- services/targo-hub/lib/acceptance.js | 7 + services/targo-hub/lib/campaigns.js | 81 +++- services/targo-hub/lib/conversation.js | 104 ++++- services/targo-hub/lib/gmail.js | 21 +- services/targo-hub/lib/modem-bridge.js | 5 + services/targo-hub/lib/payments.js | 110 ++++- services/targo-hub/lib/telephony.js | 2 + services/targo-hub/server.js | 35 +- .../templates/transactional-excuse-en.html | 30 ++ .../templates/transactional-excuse-en.json | 389 ++++++++++++++++++ .../templates/transactional-excuse-fr.html | 30 ++ .../templates/transactional-excuse-fr.json | 389 ++++++++++++++++++ 26 files changed, 1652 insertions(+), 100 deletions(-) create mode 100644 apps/ops/.env.production create mode 100644 apps/ops/src/modules/campaigns/components/ChannelToggle.vue create mode 100644 apps/ops/src/modules/campaigns/components/SenderField.vue create mode 100644 services/targo-hub/templates/transactional-excuse-en.html create mode 100644 services/targo-hub/templates/transactional-excuse-en.json create mode 100644 services/targo-hub/templates/transactional-excuse-fr.html create mode 100644 services/targo-hub/templates/transactional-excuse-fr.json diff --git a/.gitignore b/.gitignore index 8467616..d9fc5f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ apps/**/.env apps/**/.env.local +# Legacy migration exports — customer PII, never commit (purged from history 2026-06-16) +scripts/migration/genieacs-export/*.tsv +scripts/migration/genieacs-export/devices-*.json + # Dependencies node_modules/ diff --git a/apps/client/deploy.sh b/apps/client/deploy.sh index b348ad0..b77bc67 100755 --- a/apps/client/deploy.sh +++ b/apps/client/deploy.sh @@ -30,9 +30,9 @@ echo "==> Installing dependencies..." npm ci --silent echo "==> Building PWA (base=/ for portal.gigafibre.ca)..." -# VITE_ERP_TOKEN is still needed by a few API calls that hit ERPNext -# directly (catalog, invoice PDFs). TODO: migrate these behind the hub. -VITE_ERP_TOKEN="***ERP-TOKEN-REDACTED-20260616***" DEPLOY_BASE=/ npx quasar build -m pwa +# Do NOT bake an ERPNext token into the portal bundle. Any direct ERPNext +# calls (catalog, invoice PDFs) must go through a server-side token proxy. +DEPLOY_BASE=/ npx quasar build -m pwa if [ "${1:-}" = "local" ]; then echo "" diff --git a/apps/client/src/api/payments.js b/apps/client/src/api/payments.js index 3d8356b..fa4a7b5 100644 --- a/apps/client/src/api/payments.js +++ b/apps/client/src/api/payments.js @@ -3,8 +3,17 @@ */ const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' +// Attach the customer's magic-link JWT (stored by the customer store) so the hub can +// authorize per-customer access to balance/methods/invoice/portal/toggle-ppa — see +// stores/customer.js TOKEN_KEY (kept in sync). +function authHeaders (base = {}) { + let t = '' + try { t = sessionStorage.getItem('targo_portal_jwt') || '' } catch { t = '' } + return t ? { ...base, Authorization: 'Bearer ' + t } : base +} + async function hubGet (path) { - const r = await fetch(HUB + path) + const r = await fetch(HUB + path, { headers: authHeaders() }) if (!r.ok) { const data = await r.json().catch(() => ({})) throw new Error(data.error || `Hub ${r.status}`) @@ -15,7 +24,7 @@ async function hubGet (path) { async function hubPost (path, body) { const r = await fetch(HUB + path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }) const data = await r.json().catch(() => ({})) diff --git a/apps/client/src/stores/customer.js b/apps/client/src/stores/customer.js index 2ef3750..01d98a7 100644 --- a/apps/client/src/stores/customer.js +++ b/apps/client/src/stores/customer.js @@ -2,6 +2,10 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { getPortalUser } from 'src/api/portal' +// sessionStorage key holding the raw magic-link JWT for the current tab session. +// Read by src/api/payments.js to authorize /payments/* calls — keep the two in sync. +const TOKEN_KEY = 'targo_portal_jwt' + /** * Customer session store. * @@ -60,17 +64,20 @@ export const useCustomerStore = defineStore('customer', () => { const email = ref('') const customerId = ref('') const customerName = ref('') + const token = ref('') const loading = ref(true) const error = ref(null) function hydrateFromToken () { - const token = readTokenFromLocation() - if (!token) return false - const payload = decodeJwtPayload(token) + const t = readTokenFromLocation() + if (!t) return false + const payload = decodeJwtPayload(t) if (!payload || payload.scope !== 'customer') return false customerId.value = payload.sub customerName.value = payload.name || payload.sub email.value = payload.email || '' + token.value = t + try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* private mode */ } stripTokenFromUrl() return true } @@ -82,6 +89,24 @@ export const useCustomerStore = defineStore('customer', () => { // Path 1: magic-link token in URL if (hydrateFromToken()) return + // Restore a prior magic-link session (sessionStorage) so a page reload keeps + // both the identity AND the raw token we send on /payments/* calls. + if (!customerId.value) { + let saved = '' + try { saved = sessionStorage.getItem(TOKEN_KEY) || '' } catch { saved = '' } + if (saved) { + const payload = decodeJwtPayload(saved) + if (payload && payload.scope === 'customer') { + token.value = saved + customerId.value = payload.sub + customerName.value = payload.name || payload.sub + email.value = payload.email || '' + return + } + try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* expired/invalid */ } + } + } + // Already authenticated in this tab? (e.g. subsequent nav) if (customerId.value) return @@ -102,8 +127,10 @@ export const useCustomerStore = defineStore('customer', () => { email.value = '' customerId.value = '' customerName.value = '' + token.value = '' error.value = null + try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* ignore */ } } - return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear } + return { email, customerId, customerName, token, loading, error, init, hydrateFromToken, clear } }) diff --git a/apps/ops/.env.production b/apps/ops/.env.production new file mode 100644 index 0000000..76361e9 --- /dev/null +++ b/apps/ops/.env.production @@ -0,0 +1,6 @@ +# Production builds MUST NOT bake the ERPNext token into the JS bundle. +# In prod the ops-frontend nginx injects `Authorization: token …` server-side +# (see apps/ops/infra/nginx.conf). The client calls same-origin /ops/api and never +# holds a token. Leaving this empty keeps the admin key out of the shipped bundle. +# Local dev still uses apps/ops/.env (which may set VITE_ERP_TOKEN for `quasar dev`). +VITE_ERP_TOKEN= diff --git a/apps/ops/infra/nginx.conf b/apps/ops/infra/nginx.conf index 8726449..c8ae2da 100644 --- a/apps/ops/infra/nginx.conf +++ b/apps/ops/infra/nginx.conf @@ -12,7 +12,7 @@ server { proxy_pass https://erp.gigafibre.ca; proxy_ssl_verify off; proxy_set_header Host erp.gigafibre.ca; - proxy_set_header Authorization "token ***ERP-TOKEN-REDACTED-20260616***"; + proxy_set_header Authorization "token __ERP_API_TOKEN__"; # injected at deploy — never commit a real token proxy_set_header X-Authentik-Email $http_x_authentik_email; proxy_set_header X-Authentik-Username $http_x_authentik_username; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index eda10c3..aefb323 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -69,6 +69,14 @@ export function sendCampaign (id) { }) } +// Duplicate a campaign as a fresh draft (id "_2", recipients reset to pending). +// Returns the new draft campaign so the caller can open it for review/send. +export function cloneCampaign (id) { + return hubFetch(`/campaigns/${encodeURIComponent(id)}/clone`, { + method: 'POST', + }) +} + // Permanent deletion — removes the JSON on the hub. Used for clearing // test campaigns from the list. Giftbit shortlinks are unaffected. export function deleteCampaign (id) { diff --git a/apps/ops/src/components/shared/ConversationPanel.vue b/apps/ops/src/components/shared/ConversationPanel.vue index 5b26fda..e3c0d6c 100644 --- a/apps/ops/src/components/shared/ConversationPanel.vue +++ b/apps/ops/src/components/shared/ConversationPanel.vue @@ -1,8 +1,8 @@