Skip to content

feat(demo): harden hosted demo against egress + identity mutations#184

Merged
TerrifiedBug merged 3 commits intomainfrom
feat/demo-mode-guards
Apr 27, 2026
Merged

feat(demo): harden hosted demo against egress + identity mutations#184
TerrifiedBug merged 3 commits intomainfrom
feat/demo-mode-guards

Conversation

@TerrifiedBug
Copy link
Copy Markdown
Owner

Summary

Public demo at https://demo.vectorflow.sh runs with a shared seeded account. PR #172 (hosted demo mode) only hid UI surface; backend procedures and outbound services were unguarded, so a determined visitor could:

  • mint long-lived API tokens via serviceAccount.create and bypass demo entirely from a script
  • escalate privileges / mutate users via the super-admin router
  • trigger outbound HTTP from server-side code: alert webhooks, channel deliveries (slack/email/pagerduty/webhook), AI provider, git sync clone+push, gitops promotion PRs, SMTP

This PR closes those gaps with two layers of defense.

1. Procedure-level: denyInDemo() tRPC middleware

New middleware in src/trpc/init.ts throws FORBIDDEN when NEXT_PUBLIC_VF_DEMO_MODE=true. Applied to:

  • serviceAccount.{create,revoke,delete} — token minting
  • admin.* mutations — every super-admin user/team mutation
  • alertWebhook.testWebhook — does inline fetch(webhook.url) bypassing service guard
  • environment.testGitConnection — clones arbitrary HTTPS URLs to test creds

2. Service-level: isDemoMode() short-circuits

For routers we leave reachable so demo users can play with config UIs (alert webhook config, AI conversations, channel rules, git-sync queue), the outbound services themselves no-op in demo:

  • outbound-webhook.tsdeliverOutboundWebhook returns success-shaped result without fetch
  • webhook-delivery.tsdeliverSingleWebhook short-circuits
  • channels/index.tsdeliverToChannels returns [] (slack/email/pagerduty/webhook)
  • channels/email.ts — driver level guard (defense-in-depth)
  • cost-optimizer-ai.tsgenerateAiRecommendations skips LLM call
  • ai.tsstreamCompletion returns demo notice; testAiConnection returns error
  • git-sync.tsgitSyncCommitPipeline and gitSyncDeletePipeline return synthetic success
  • gitops-promotion.tscreatePromotionPR returns synthetic PR result

3. Auth

  • src/auth.ts — bypass TOTP challenge if isDemoMode() so the seeded shared account works regardless of its totpEnabled value
  • src/app/(dashboard)/layout.tsx — skip /setup-2fa redirect in demo so visitors don't get bounced if org-level twoFactorRequired is set

Test plan

  • pnpm test — 2456 tests pass, including new outbound-webhook demo-mode case asserting fetch is never called and validatePublicUrl is skipped
  • tsc --noEmit clean
  • eslint clean on touched files
  • Deploy demo container with NEXT_PUBLIC_VF_DEMO_MODE=true and verify:
    • Login works with seeded credentials, no 2FA prompt
    • Creating a webhook + clicking "test" does NOT hit external URL (check Network tab and server logs)
    • serviceAccount.create returns FORBIDDEN from a forced tRPC call
    • environment.testGitConnection returns FORBIDDEN
    • Alert firing does NOT deliver to slack/email channels

Public demo at https://demo.vectorflow.sh runs with a shared seeded
account. The previous demo PR only hid UI surface; backend procedures
and outbound services were unguarded, so a determined visitor could
call tRPC directly to mint API tokens, change identity settings, or
trigger outbound HTTP from server-side code (alert webhooks, AI
provider, git sync, SMTP, gitops PRs).

This change closes those gaps:

- Add denyInDemo() tRPC middleware for procedures we never want
  reachable in demo (token minting, super-admin user mgmt, git test
  connection that clones arbitrary URLs).
- Add isDemoMode() short-circuits inside the outbound services so
  even procedures we leave reachable for UX (alert webhook config,
  AI conversations, channel rules, git-sync queue) can never
  actually deliver: outbound-webhook, webhook-delivery, channels
  dispatcher (slack/email/pagerduty/webhook), email driver,
  cost-optimizer-ai, ai streamCompletion + testAiConnection,
  git-sync commit/delete, gitops-promotion PR creation.
- Bypass TOTP challenge in src/auth.ts and the /setup-2fa redirect
  in the dashboard layout when running in demo, so the seeded
  account can sign in regardless of org-level 2FA enforcement.

Defense-in-depth: most paths are now blocked at both the procedure
boundary AND inside the service that does the I/O.

Tests: extended outbound-webhook tests with a demo-mode case
asserting fetch is never called and validatePublicUrl is skipped.
Full vitest suite (2456 tests) passes; tsc --noEmit clean.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 26, 2026

Greptile Summary

This PR hardens the hosted public demo against egress and identity mutations using two complementary layers: a denyInDemo() tRPC middleware that throws FORBIDDEN at the procedure level for credential-minting and privilege-escalation operations, and isDemoMode() short-circuits inside outbound services (webhooks, channels, AI, git-sync, gitops promotion) to suppress all external HTTP calls. Auth is also updated to bypass TOTP for the shared seeded account and skip the /setup-2fa redirect. Coverage across all 16 changed files appears complete — all admin mutations, service account mutations, and the two inline-fetch procedures (testWebhook, testGitConnection) are correctly guarded.

Confidence Score: 4/5

Safe to merge; only a P2 test cleanup pattern was found, with no production correctness or security issues.

All 16 files implement the demo hardening correctly. The single finding is a P2 test hygiene issue (env stub not wrapped in try/finally), which cannot cause production misbehavior. P2-only findings cap at 4/5.

src/server/services/outbound-webhook.test.ts — test cleanup pattern

Important Files Changed

Filename Overview
src/trpc/init.ts Adds denyInDemo() middleware factory that throws FORBIDDEN when NEXT_PUBLIC_VF_DEMO_MODE=true; correctly short-circuits before any DB auth queries.
src/auth.ts Bypasses TOTP challenge when isDemoMode() is true so the seeded shared account works; all other auth checks (password, lockout, OIDC block) remain intact.
src/app/(dashboard)/layout.tsx Skips /setup-2fa redirect in demo mode; client-side NEXT_PUBLIC_ var is inlined at build time as expected.
src/server/routers/admin.ts All 8 admin mutations now have denyInDemo() placed before requireSuperAdmin(); read-only queries (listUsers, listTeams) correctly left unguarded.
src/server/routers/service-account.ts create/revoke/delete all guarded with denyInDemo(); list query intentionally left open for demo UI display.
src/server/services/outbound-webhook.test.ts New demo-mode test added; inline env/global stub cleanup is fragile if assertions fail before vi.unstubAllEnvs() is reached (P2).
src/server/services/outbound-webhook.ts isDemoMode() guard returns a settled success result before SSRF check and fetch; non-retryable shape is correct for delivery record bookkeeping.
src/server/services/git-sync.ts Both gitSyncCommitPipeline and gitSyncDeletePipeline return synthetic success in demo mode before any git clone/push operations.
src/server/services/gitops-promotion.ts createPromotionPR returns a synthetic PR result with example.invalid URL in demo mode; no real git provider calls are made.
src/server/services/channels/index.ts deliverToChannels short-circuits at the top level in demo mode, returning [] before reading any channel configs or driver dispatch.
src/server/services/channels/email.ts Defense-in-depth guard at driver level; returns success-shaped result before SMTP connection is attempted.
src/server/services/ai.ts streamCompletion emits a notice token and returns; testAiConnection returns a non-ok result; both correctly skip LLM API calls in demo mode.
src/server/services/cost-optimizer-ai.ts generateAiRecommendations returns 0 in demo mode before querying pending recommendations or calling the LLM.
src/server/services/webhook-delivery.ts deliverSingleWebhook short-circuits before SSRF validation and fetch in demo mode; returns a settled success result.
src/server/routers/alert-webhooks.ts testWebhook mutation guarded with denyInDemo() before withTeamAccess; blocks the inline fetch that bypasses the service layer guard.
src/server/routers/environment.ts testGitConnection mutation guarded with denyInDemo() to prevent cloning arbitrary HTTPS URLs in demo mode.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Demo visitor request] --> B{isDemoMode?}
    B -- "No" --> C[Normal path]
    B -- "Yes" --> D{Request type}
    D -- "Credentials login" --> E[TOTP check bypassed\nsrc/auth.ts]
    D -- "tRPC mutation\nservice-account / admin\nalert-webhook.test\nenv.testGitConnection" --> F[denyInDemo middleware\nthrows FORBIDDEN\nsrc/trpc/init.ts]
    D -- "Outbound delivery\nwebhook / channel / AI\ngit-sync / gitops" --> G[Service-level guard\nisDemoMode short-circuit\nreturns synthetic success]
    E --> H[Session established\nno 2FA prompt\nlayout.tsx guard]
    F --> I[FORBIDDEN returned to client]
    G --> J[No external HTTP call\ndelivery record settles cleanly]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/server/services/outbound-webhook.test.ts
Line: 214-227

Comment:
**Env/global stubs won't clean up on assertion failure**

`vi.unstubAllEnvs()` and `vi.unstubAllGlobals()` are called inline after the assertions. If either `expect` call fails, the cleanup never runs — leaving `NEXT_PUBLIC_VF_DEMO_MODE=true` and the global `fetch` stub active for subsequent tests. `vi.clearAllMocks()` in `beforeEach` resets call counts but does NOT restore `vi.stubEnv` state.

Wrap the body in `try/finally` to guarantee cleanup:

```suggestion
  it("never calls fetch when NEXT_PUBLIC_VF_DEMO_MODE=true", async () => {
    vi.stubEnv("NEXT_PUBLIC_VF_DEMO_MODE", "true");
    const fetchSpy = vi.fn();
    vi.stubGlobal("fetch", fetchSpy);

    try {
      const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);

      expect(fetchSpy).not.toHaveBeenCalled();
      expect(vi.mocked(urlValidation.validatePublicUrl)).not.toHaveBeenCalled();
      expect(result.success).toBe(true);
    } finally {
      vi.unstubAllEnvs();
      vi.unstubAllGlobals();
    }
  });
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(demo): harden hosted demo against e..." | Re-trigger Greptile

Comment on lines +214 to +227
it("never calls fetch when NEXT_PUBLIC_VF_DEMO_MODE=true", async () => {
vi.stubEnv("NEXT_PUBLIC_VF_DEMO_MODE", "true");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);

const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);

expect(fetchSpy).not.toHaveBeenCalled();
expect(vi.mocked(urlValidation.validatePublicUrl)).not.toHaveBeenCalled();
expect(result.success).toBe(true);

vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Env/global stubs won't clean up on assertion failure

vi.unstubAllEnvs() and vi.unstubAllGlobals() are called inline after the assertions. If either expect call fails, the cleanup never runs — leaving NEXT_PUBLIC_VF_DEMO_MODE=true and the global fetch stub active for subsequent tests. vi.clearAllMocks() in beforeEach resets call counts but does NOT restore vi.stubEnv state.

Wrap the body in try/finally to guarantee cleanup:

Suggested change
it("never calls fetch when NEXT_PUBLIC_VF_DEMO_MODE=true", async () => {
vi.stubEnv("NEXT_PUBLIC_VF_DEMO_MODE", "true");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
expect(fetchSpy).not.toHaveBeenCalled();
expect(vi.mocked(urlValidation.validatePublicUrl)).not.toHaveBeenCalled();
expect(result.success).toBe(true);
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it("never calls fetch when NEXT_PUBLIC_VF_DEMO_MODE=true", async () => {
vi.stubEnv("NEXT_PUBLIC_VF_DEMO_MODE", "true");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
try {
const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
expect(fetchSpy).not.toHaveBeenCalled();
expect(vi.mocked(urlValidation.validatePublicUrl)).not.toHaveBeenCalled();
expect(result.success).toBe(true);
} finally {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
}
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/server/services/outbound-webhook.test.ts
Line: 214-227

Comment:
**Env/global stubs won't clean up on assertion failure**

`vi.unstubAllEnvs()` and `vi.unstubAllGlobals()` are called inline after the assertions. If either `expect` call fails, the cleanup never runs — leaving `NEXT_PUBLIC_VF_DEMO_MODE=true` and the global `fetch` stub active for subsequent tests. `vi.clearAllMocks()` in `beforeEach` resets call counts but does NOT restore `vi.stubEnv` state.

Wrap the body in `try/finally` to guarantee cleanup:

```suggestion
  it("never calls fetch when NEXT_PUBLIC_VF_DEMO_MODE=true", async () => {
    vi.stubEnv("NEXT_PUBLIC_VF_DEMO_MODE", "true");
    const fetchSpy = vi.fn();
    vi.stubGlobal("fetch", fetchSpy);

    try {
      const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);

      expect(fetchSpy).not.toHaveBeenCalled();
      expect(vi.mocked(urlValidation.validatePublicUrl)).not.toHaveBeenCalled();
      expect(result.success).toBe(true);
    } finally {
      vi.unstubAllEnvs();
      vi.unstubAllGlobals();
    }
  });
```

How can I resolve this? If you propose a fix, please make it concise.

CI failed on the demo-guards branch because four existing router tests
mock @/trpc/init with a partial export list. Adding denyInDemo to the
real init.ts module surfaced as 'No denyInDemo export is defined on the
@/trpc/init mock' inside alert-webhooks, environment, service-account,
and admin tests.

Add denyInDemo to each mock's passthrough export so the import resolves
without changing test behavior.
Previous fix only covered four files; CI still failed because alert.ts
re-exports alert-webhooks (whose router now imports denyInDemo) and
many other test files mock @/trpc/init partially. Patch every test
file that mocks @/trpc/init so the import resolves regardless of which
router gets pulled in transitively.
@TerrifiedBug TerrifiedBug merged commit 2300c2c into main Apr 27, 2026
12 checks passed
@TerrifiedBug TerrifiedBug deleted the feat/demo-mode-guards branch April 27, 2026 07:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant