SECURITY_MODEL — bff-consumer-service
Sibling: API_CONTRACTS · DATA_MODEL · APPLICATION_LOGIC · FAILURE_MODES
Cross-cutting: 07 Security/Compliance/Tenancy · Standards · ERROR_CODES
1. Threat model summary
| Threat | Surface | Primary control | Secondary control |
|---|---|---|---|
| Cross-tenant data leak | Composition logic across tenants | We read only from search-aggregation-service (already projected) and the read-only pricing-service /quotes/preview; never from authoritative per-tenant write models | Code-review checklist + ESLint import boundary blocking direct reservation/inventory client imports |
| Handoff token forgery | BookingHandoff issued by us, consumed by bff-tenant-booking-service | HMAC-SHA256 with hmacKeyId rotation; canonical signing string (no JSON ordering ambiguity) | handoff_replay_log durable record; consumed flag; cross-BFF POST /internal/handoff/{id}/consume |
| Handoff token replay | Handoff verification | Single-use consumed flag with optimistic concurrency; receiving BFF marks consumed before user-visible action | Replay attempt emits bot_suspected.v1 and increments fingerprint penalty |
| Session hijacking | gms_ cookie in transit | HttpOnly, Secure, SameSite=Lax, 30-day TTL; cookie value is high-entropy ULID | Cookie binding to cookieFingerprintHash (UA + Accept-Language + screen + tz) — mismatched fingerprint triggers re-bootstrap and emits bot_suspected.v1 |
| Bot abuse / scraping | All endpoints | Edge: Cloud Armor + reCAPTCHA Enterprise; app-tier: rules-based BotDetector + token-bucket rate limit | Per-fingerprint cadence list; 7-day bot_score_log for forensic |
| Cache poisoning | Memorystore via single-flight | Single-flight key includes locale + currency + queryHash; no user-controlled data goes into cache key construction unsanitised | Periodic cache audit (sample compare to live upstream) |
| PII leak via telemetry | Pub/Sub events | We hash IP, UA, fingerprint with peppered SHA-256; we never emit raw IP/UA/email/name | Per-event schema validation in CI; sampled DLP scan in BigQuery sink |
| DDoS via campaign spike | All anonymous endpoints | Cloud Armor adaptive rules; Cloud CDN absorption; per-instance rate-limit; campaign_mode raises cache TTL | Auto-scale to 30 instances; circuit-break upstream calls if budget exceeded |
| Tenant suspension bypass | Handoff to suspended tenant | Suspended-tenant set in Memorystore + Postgres mirror; checked at mint time | Periodic re-fetch from tenant-service; pessimistic deny on cache miss |
| HMAC key compromise | Secret Manager rotation | Active + grace key set; new tokens always sign with active; verifying BFF accepts both | 90-day rotation; emergency rotation runbook ≤ 30 min |
| Open redirect via handoff URL | redirectUrl host construction | Host is computed server-side from tenant.tenantSlug, never accepted from client | Allow-list of host suffix .melmastoon.ghasi.io; CI test asserts this |
| Cookie injection / CSRF | Mutating routes | SameSite=Lax + double-submit X-Idempotency-Key (header) requirement on all mutations; Origin header check on POST | Origin allow-list of *.melmastoon.ghasi.io and known partner-marketing domains |
| Currency confusion | X-Currency override | Strict allow-list {AFN, USD, EUR, IRR, PKR, AED, GBP}; rejected with 422 otherwise | Per-currency rounding policy enforced upstream |
2. Authentication
This BFF is anonymous-first. There are three trust modes:
| Mode | Phase | Identity carrier | Where validated |
|---|---|---|---|
| Anonymous | 1 (current) | gms_ cookie | SessionBootstrapInterceptor |
| Anonymous + reCAPTCHA challenge | 1 | gms_ cookie + X-Recaptcha-Token | BotDetectionInterceptor |
| Authenticated consumer | 2 | Authorization: Bearer <jwt> from iam-service (consumer realm) | JwtConsumerGuard (gated by feature flag, off in Phase 1) |
A tenant-staff JWT is never accepted on this BFF. If one is presented, the request is rejected with MELMASTOON.BFF.SURFACE_MISMATCH (403).
3. Cookie + session security
Set-Cookie: gms_id=gms_01H...; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000; Path=/
- High-entropy ULID (128 bits).
HttpOnlyblocks JavaScript access (XSS-resistant).Securerequires HTTPS.SameSite=Laxprotects against most CSRF; mutating routes additionally requireX-Idempotency-Keyand anOriginheader check.Max-Age=2592000(30 days). Inactive sessions are TTL-evicted from Memorystore on the same schedule.
cookieFingerprintHash = sha256(pepper + userAgent + acceptLanguage + screenSize + timezoneOffset). On every request we compare; mismatch triggers re-bootstrap and emits bot_suspected.v1 with kind='fingerprint-collision'.
4. Authorization
Only two authorization decisions exist on this BFF:
- Wishlist + session ownership — every read/write under
/wishlist/*and/session/*requires the request to carry thegms_cookie that owns the row. Cookie absent → bootstrap; cookie mismatch on identifier → 403MELMASTOON.BFF.CONSUMER.SESSION_OWNERSHIP_VIOLATION. - Handoff target tenant not suspended — checked at
POST /handoffmint time and again at the receiving BFF.
There is no RBAC, no ABAC, and no RLS on this BFF (no tenant_id column on any owned row).
5. HMAC handoff signing
5.1 Algorithm
HMAC-SHA256 over the canonical signing string defined in DOMAIN_MODEL §2.4. Output base64url-encoded; no padding.
5.2 Key management
- Keys live in Google Secret Manager at
projects/<gcp-project>/secrets/bff-consumer-handoff-hmac/versions/<n>. - Service account
bff-consumer-sa@<project>.iam.gserviceaccount.comhasroles/secretmanager.secretAccessoron the secret. - Each key has a
keyIdlikehmac-2026-04; the keyId is included in the token payload so the receiving BFF can disambiguate. - Rotation cadence: 90 days. The active key is used for signing; the previous key is kept in the grace set for verification only for 14 days after rotation.
- Emergency rotation runbook: rotate, deploy both BFFs to pick up the new active version, re-publish a new active id within 30 minutes; old tokens minted before rotation continue to verify until they expire (max 30 min window).
5.3 Verification (performed by bff-tenant-booking-service)
Documented here for completeness so both BFFs share the same understanding:
1. Parse v1.<payload-b64>.<sig-b64>.
2. Decode payload JSON.
3. Reject if version != 'v1'.
4. Reject if expiresAt < now or mintedAt > now + 60 s skew tolerance.
5. Look up key by hmacKeyId; reject if not in {active, grace}.
6. Reconstruct canonical signing string per DOMAIN_MODEL §2.4.
7. HMAC-verify; reject on mismatch.
8. POST /internal/handoff/{id}/consume to bff-consumer; receive 'ok' | 'already-consumed'.
9. If 'already-consumed', reject with MELMASTOON.BFF.TENANT.HANDOFF_REPLAYED and emit bot_suspected.v1 on the receiving side.
6. Bot mitigation
6.1 Layers
| Layer | Mechanism | Decision |
|---|---|---|
| Edge | Cloud Armor preconfigured rules + adaptive protection | Hard-block clear-bot patterns at the LB |
| Edge | reCAPTCHA Enterprise (invisible) on /search, /handoff | Score threshold 0.3 → challenge |
| App | UA pattern matcher (curated list incl. headlessChrome, common scrapers) | Weight 0.4 in BotScore.signals |
| App | Cadence (sliding window, last 60 s requests per fingerprint) | Weight 0.3 |
| App | Fingerprint collision (>50 sessions sharing fp in 24 h) | Weight 0.2 |
| App | Header anomaly (missing Accept, malformed Accept-Language) | Weight 0.1 |
| App (Phase 2) | LLM-augmented judge via ai-orchestrator-service | Advisory |
6.2 Verdict thresholds
score < 0.6 → 'human' → no action
0.6 ≤ s < 0.85 → 'suspect' → challenge with reCAPTCHA, bff returns 429 + X-Recaptcha-SiteKey
score ≥ 0.85 → 'bot' → hard-block, returns 429, increments soft-deny counter on fingerprint
bot_suspected.v1 events are emitted at 100% sample for verdict ∈ {suspect, bot}.
7. Rate limiting
| Bucket | Capacity | Refill | Cost per request |
|---|---|---|---|
consumer:search:<fingerprint> | 600 | 600/min | 1 |
consumer:search:<ip> | 1200 | 1200/min | 1 |
consumer:handoff:<fingerprint> | 30 | 30/min | 5 (handoff is heavy) |
consumer:wishlist:<fingerprint> | 60 | 60/min | 1 |
consumer:session:<fingerprint> | 30 | 30/min | 1 |
consumer:telemetry:<fingerprint> | 600 | 600/min | 1 |
Buckets are token-bucket implemented as Redis counters (rl:<bucket> key + rl:<bucket>:reset). Edge adds a coarse per-IP layer at Cloud Armor (per-IP-1000-rpm).
8. Tenancy isolation (the BFF's special posture)
This BFF is the only Melmastoon service whose owned data is not tenant-scoped. The tenancy isolation guarantee here is therefore inverted:
- No row owned by this service references a tenant for tenancy enforcement —
tenantIdappears only as a target reference onBookingHandoffand a cache-key dimension onBrandPeek. - All cross-tenant reads go through
search-aggregation-serviceand never include PII fields per 02 §6.3. The CI testcross-tenant-read-allowlist.spec.tsenforces that the only HTTP clients we instantiate are for the four allow-listed upstream services. - Postgres has no RLS because there is no
tenant_idcolumn.
A separate CI test no-tenant-id-column.spec.ts parses the Drizzle schema and asserts no tenant_id-named column exists outside handoff_replay_log (where it is a target reference) and wishlist_anonymous (same) and tenant_suspended_cache (where it is the natural primary key for the cache).
9. Encryption + secrets
| Data | At rest | In transit | Notes |
|---|---|---|---|
| Postgres rows | Cloud SQL CMEK with per-environment KMS key | TLS 1.3 (Cloud SQL connector) | KMS key rotation 365 d |
| Memorystore values | AES-256 (Memorystore default) | TLS 1.2+ (Memorystore in-transit encryption enabled) | n/a |
| HMAC handoff secret | Secret Manager + CMEK | TLS to Secret Manager | 90-day rotation |
| reCAPTCHA site key | Secret Manager (Cloud build inject) | TLS | Rotated on partner request |
| Pepper for hashes | Secret Manager | TLS | Annual rotation; old peppers retained for join continuity |
| HTTP cookies | n/a | TLS only | No persistence outside browser |
10. Audit logging
| Event | Sink | Retention |
|---|---|---|
| Handoff mint | melmastoon.bff.consumer.handoff.initiated.v1 → audit-service | 1 year |
| Bot detected (suspect or bot) | melmastoon.bff.consumer.bot_suspected.v1 → audit-service | 1 year |
| Handoff replay attempt | melmastoon.bff.consumer.bot_suspected.v1 (kind=handoff-replay) | 1 year |
| Tenant-suspension bypass attempt | Cloud Logging structured log + bot_suspected.v1 | 1 year |
| HMAC key rotation | Cloud Audit Logs (Secret Manager) | 7 years |
| Postgres CMEK key access | Cloud Audit Logs | 7 years |
| Cookie ownership violation | MELMASTOON.BFF.CONSUMER.SESSION_OWNERSHIP_VIOLATION → structured log | 90 days |
All structured logs include requestId, traceId, guestSessionId (when available), ipHash, fingerprintHash. Raw IP/UA never appear in logs.
11. GDPR + DSAR
- Right to access (Article 15): A guest can request their session data via the consent banner UI (Phase 1.5). The BFF returns the sanitised
GuestSessionblob + thewishlist_anonymousrows + a paginated export ofMetaPageViewandConversionFunnelEventrows joined onguestSessionId. - Right to erasure (Article 17):
POST /session/clearpurges Memorystore + Postgres mirrors + queues a 30-day BigQuery sweep job for telemetry events. - Right to portability (Article 20): JSON export from the access-request endpoint.
- Lawful basis: Legitimate interest (telemetry sampling for product improvement); consent (marketing flag opt-in); contract (handoff necessary for booking initiation).
- No PII is collected or stored Phase 1; the obligations above apply to derived identifiers (
guestSessionId, hashed network identifiers).
12. Security headers (response)
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(self), camera=(), microphone=()
Content-Security-Policy: default-src 'none'; connect-src 'self' https://api.melmastoon.ghasi.io
Cross-Origin-Resource-Policy: same-site
CSP is enforced by the gateway; the BFF emits these headers as belt-and-braces.
13. Dependency + supply-chain security
- All workspace dependencies pinned via
pnpm-lock.yaml. pnpm auditblocks PRs with high/critical vulnerabilities.- SBOM published on every release via
cyclonedx-npm. - Container images built on Cloud Build, signed with Cosign, deployed to Cloud Run with binary authorization policy
require-cosign-signature.
14. Pen-test scope (annual)
- Anonymous handoff token forgery + replay.
- Cookie hijacking.
- CSRF on mutating routes.
- Cache poisoning via crafted query parameters.
- Cross-tenant data exposure via search params.
- Bot bypass (curated tooling: ZAP, Burp, custom headless harness).
15. Incident playbook references
| Scenario | Runbook |
|---|---|
| HMAC key suspected compromise | runbooks/bff-consumer/hmac-rotation-emergency.md |
| Bot wave detected | runbooks/bff-consumer/bot-wave.md |
| Tenant-suspended cache desync | runbooks/bff-consumer/tenant-suspended-cache-desync.md |
| Handoff replay alert spike | runbooks/bff-consumer/handoff-replay-spike.md |
| Memorystore failover | runbooks/bff-consumer/memorystore-failover.md |