Skip to content

fix(security): P1 sweep - GH-273, GH-257, GH-103, GH-101, GH-100, GH-093, GH-079, GH-078, GH-053, GH-054 - #3576

Draft
dennisofficial wants to merge 9 commits into
mainfrom
fix/p1-security-sweep
Draft

dennisofficial wants to merge 9 commits into
mainfrom
fix/p1-security-sweep

Conversation

@dennisofficial

@dennisofficial dennisofficial commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Draft PR fixing 10 confirmed P1 findings from the Glass House sweep (GH-273, GH-257, GH-103, GH-101, GH-100, GH-93, GH-79 + duplicate GH-245, GH-78, GH-53, GH-54). One commit per finding. All were re-validated against origin/main HEAD before fixing — none were stale.

GH-273 — mcp-server serve: wildcard CORS + 0.0.0.0 bind + static key fallback

Wrong: serve bound 0.0.0.0:2718, set Access-Control-Allow-Origin: * on every response, and POST /mcp had no auth — buildSDK silently falls back to the CLI --apikey. Any website the operator visited could drive the local MCP server with their full-privilege key; the LAN could reach it too.
Changed: bind 127.0.0.1 by default (new --host flag to opt out); wildcard CORS middleware removed (MCP HTTP clients are not browsers); POST /mcp rejects requests with a non-localhost Origin header (browsers always send one; header-less local MCP clients keep working with --apikey). --disable-static-auth semantics unchanged.
Verified: tsc --noEmit clean in apps/mcp-server (own install — it's outside the bun workspaces).

GH-257 — task status route returned any run's output to any authenticated user

Wrong: GET /api/tasks/[taskId]/status called runs.retrieve(taskId) and returned output/status/error with no ownership check — cross-tenant read of AI-generated policy/questionnaire/vendor content given a run id.
Changed: every user-triggerable tasks.trigger call site in apps/app now tags the run with the owning organizationId; the route requires run.tags to include the caller's active org and returns the same 404 as a missing run otherwise. research-vendor (shared GlobalVendors data, reachable pre-onboarding) is tagged when an active org exists.
Verified: new route.test.ts (5 cases: unauthenticated, no org, cross-tenant 404, untagged 404, own-org 200) passes; tsc --noEmit clean on all 18 touched files.

GH-103 — Upstash Vector filter injection via organizationId

Wrong: findSimilarContent/findSimilarContentBatch built the metadata filter by raw interpolation (organizationId = "${organizationId}"); a value containing " could append arbitrary clauses (OR organizationId GLOB "*") and dump every tenant's RAG chunks. The org id is reachable from the answer-question task payload.
Changed: strict allowlist (/^[a-zA-Z0-9_-]+$/, the prefixed-CUID shape) with fail-closed guard before the filter string is built — in both query paths and the same-pattern sink in sync-organization.ts (embedding readiness check). The apps/app copy does client-side post-filtering only and is not vulnerable.
Verified: 14/14 jest tests pass in src/vector-store, incl. new tests replaying the literal exploit payload against both functions.

GH-101 — Sentry Session Replay unmasked (maskAllText: false)

Wrong: production replay ran with maskAllText: false / blockAllMedia: false (10% of sessions, 100% of error sessions) and the sentry-mask escape hatch was used in zero of ~1470 components — verbatim capture of customer compliance data.
Changed: maskAllText: true, blockAllMedia: true in apps/app and apps/portal; data-sentry-unmask is now the opt-in for provably safe elements.
Verified: typecheck clean on both files.

GH-100 — hardcoded 'fallback-secret' HMAC key for unsubscribe tokens

Wrong: @trycompai/email derived the unsubscribe-token HMAC key from UNSUBSCRIBE_SECRET || AUTH_SECRET || 'fallback-secret'; apps/api never sets either and verifies the unauthenticated POST /v1/email/unsubscribe with it — every token forgeable from source.
Changed: fallback literal removed; the secret resolves lazily and token generation throws when unconfigured (fail closed; importing the package never throws). UNSUBSCRIBE_SECRET documented in apps/api/.env.example. The separate apps/app/src/lib/unsubscribe.ts implementation was checked — it has no fallback and needs no change.
Verified: all call sites are request/task-time, so lazy resolution is safe; packages/email typecheck shows only pre-existing render.test.tsx errors.

GH-93 — portal policy-signature endpoints write to any org's policies

Wrong: mark-policy-completed looked the member up with no org filter and the policy by bare id; accept-policies verified the memberId but pushed it into signedBy of policies in any organization.
Changed: mark-policy-completed resolves the member within the policy's organization (403 otherwise — also fixes the arbitrary-membership pick for multi-org users); accept-policies skips policies outside the member's organization.
Verified: tsc --noEmit clean on all touched portal files.

GH-79 (+ duplicate GH-245) — portal policy-pdf-url trusts versionId

Wrong: the versionId branch did a bare findUnique({ where: { id: versionId } }) and presigned whatever pdfUrl came back — a 15-minute presigned GET for another org's policy PDF. The main app's sibling implementation has exactly this check and comments why.
Changed: version lookup scoped to the already-org-validated policyId (where: { id: versionId, policyId }); mismatches fall through to the existing "No PDF found." response.
Verified: typecheck clean.

GH-78 — trust questionnaire upload ignores securityQuestionnaireEnabled

Wrong: POST /v1/questionnaire/parse/upload/token validated the trust access token and ran RAG auto-answering without checking Trust.securityQuestionnaireEnabled — orgs that disabled the AI questionnaire were still fully served to any token holder.
Changed: flag checked after token validation (new isSecurityQuestionnaireEnabledForOrganization on TrustAccessService, defaulting to enabled when no Trust row exists, matching the public-overview helper); 403 when disabled. This is the only @Public() token-authenticated endpoint in the controller.
Verified: 92/92 jest tests pass in src/questionnaire, incl. 3 new cases (disabled → 403 and autoAnswerAndExport never called; enabled → proceeds; no Trust row → proceeds).

GH-53 — organization logo is an unvalidated S3 key that gets presigned

Wrong: UpdateOrganizationDto.logo was a bare string, stored verbatim, and GET /v1/organization presigned it as a raw S3 key against the shared org-assets bucket (which also holds trust documents) — cross-tenant read for any org owner who knows a key. The Next.js layout duplicated the unchecked presign.
Changed: update rejects logo keys not prefixed with the org's own id (empty/null still clears); getLogoSignedUrl takes the organizationId and returns null for out-of-org keys; the app layout skips presigning out-of-org logos. Legit keys are ${organizationId}/logo/... (written by uploadLogo).
Verified: 33/33 jest tests pass in src/organization incl. new cross-org rejection cases; app typecheck clean on the layout.

GH-54 — evidence-form fileKey accepted arbitrary keys, presigned on read

Wrong: fileKey: z.string().min(1) was stored verbatim in submission data and refreshFileUrls presigned whatever was stored against the shared attachments bucket — cross-tenant read given a leaked full key.
Changed: submission create rejects any file field whose fileKey doesn't start with ${organizationId}/ (legit keys are ${organizationId}/attachments/evidence-forms/...); refreshFileUrls takes the organizationId and skips presigning (downloadUrl: null) for out-of-org keys — defense in depth for legacy rows.
Verified: 32/32 jest tests pass in src/evidence-forms, incl. 4 new guard tests.

Needs human action

  • Com 20 cloud tests #100 (required before deploy): set UNSUBSCRIBE_SECRET in the apps/api production environment (and anywhere @trycompai/email sends mail). Without it, email sending now throws instead of silently using the public fallback. Treat any secret material previously derivable as compromised: tokens minted under fallback-secret were forgeable by design, so invalidating them by setting the secret is the intended effect.
  • refactor: Improve policy overview form layout and styling #101: replays already captured in Sentry contain unmasked customer data — decide whether to purge Sentry replay history.
  • Claudio/fix hydration #257: runs triggered before this deploy carry no org tag and will 404 on the status route — in-flight runs at deploy time need re-triggering; no data migration possible (tags live on the run).
  • Lewis/editor #53: any org whose stored logo doesn't start with its own id stops rendering a logo (logoUrl → null). Worth a one-off data check (SELECT id, logo FROM organization WHERE logo IS NOT NULL AND logo NOT LIKE id || '/%'); affected rows can re-upload.
  • style: styling improvements to align w/ brand #54: legacy submissions holding out-of-org fileKeys now render downloadUrl: null instead of a presigned URL.
  • feat: add policy layout components and enhance policy details page #273: serve/impl.ts and serve/command.ts carry the Speakeasy "DO NOT EDIT" header but are hand-maintained in-repo (prior feature commits edit them too) — a future Speakeasy regeneration could drop this fix; consider upstreaming the hardening into the generation config.
  • feat: add policy layout components and enhance policy details page #273 / Com 20 cloud tests #100 both note: apps/api .env.example documents UNSUBSCRIBE_SECRET= with no value — a real secret must be generated and added to the deployed env out of band.

Verification summary

  • apps/api: bunx jest — src/vector-store 14/14, src/organization 33/33, src/evidence-forms 32/32, src/questionnaire 92/92, all passing; tsc --noEmit reports zero errors in touched files (remaining failures are pre-existing errors in unrelated spec files, identical at HEAD).
  • apps/app: bunx vitest run new status-route test 5/5; tsc --noEmit clean on all touched files.
  • apps/portal: tsc --noEmit clean on touched files.
  • apps/mcp-server: tsc --noEmit clean (workspace-excluded package, own bun install).
  • packages/email: typecheck shows only pre-existing render.test.tsx errors (confirmed identical with the change stashed).
  • Lint: eslint clean on touched files except pre-existing warnings/prettier noise also present at HEAD.

Do not merge — draft for review per the sweep protocol.


Summary by cubic

Fixes 10 confirmed P1 security findings from the Glass House sweep across apps/api, apps/app, apps/portal, apps/mcp-server, and packages/email.

Closes a cross-tenant read path (GH-257) by tagging every user-triggerable task run with its owning organization and requiring a matching tag on the status route. Closes S3 key validation gaps (GH-53, GH-54) so logo keys, evidence-form fileKeys, and presigned download URLs are scoped to the caller's organization. Closes Upstash Vector filter injection via organizationId (GH-103) with a strict allowlist. Closes the portal policy endpoints (GH-93, GH-79) by scoping lookups to the member's organization, and honors securityQuestionnaireEnabled on token-authenticated uploads (GH-78). Closes local MCP server exposure (GH-273) by binding to 127.0.0.1 by default and rejecting non-localhost Origin headers. Removes the hardcoded fallback-secret HMAC key for unsubscribe tokens (GH-100) and masks all text/media in Sentry Session Replay (GH-101).

Migration

  • Set UNSUBSCRIBE_SECRET in the apps/api production environment before deploy, otherwise email sending throws instead of silently using the public fallback. Tokens minted under the fallback secret were forgeable; resetting the secret invalidates them.
  • Runs triggered before this deploy carry no org tag and 404 on the status route; in-flight runs at deploy time need re-triggering.
  • Orgs with a stored logo not prefixed by their own id stop rendering a logo, and legacy evidence submissions holding out-of-org fileKeys render downloadUrl: null. Existing replays in Sentry contain unmasked customer data — decide whether to purge replay history.

Written for commit e2f27d6. Summary will update on new commits.

Review in cubic

GH-273: the streamable HTTP server bound 0.0.0.0 with
Access-Control-Allow-Origin: * and no request authentication, so any
website the operator visited could drive the local MCP server with the
CLI-configured API key, and the LAN could reach it too.

- bind to 127.0.0.1 by default (new --host flag to opt out)
- drop the wildcard CORS middleware (MCP HTTP clients are not browsers)
- reject POST /mcp with a non-localhost Origin header (browsers always
  send one; header-less local MCP clients keep working with --apikey)
GH-93 / GH-79 / GH-245: portal policy endpoints looked policies and
policy versions up by bare id with no organization scoping, letting any
authenticated portal user sign policies in other tenants or fetch a
presigned URL for another org's policy PDF.

- mark-policy-completed: resolve the member within the policy's
  organization (also fixes the arbitrary-membership pick for
  multi-org users)
- accept-policies: skip policies outside the member's organization
- policy-pdf-url: scope the versionId lookup to the policy whose org
  membership was already validated
GH-101: session replay ran in production with maskAllText: false and
blockAllMedia: false while the sentry-mask escape hatch was used in zero
of ~1470 components, recording customer compliance data verbatim.

Default to masking everything; data-sentry-unmask is now the opt-in for
elements provably safe to record.
GH-100: the unsubscribe-token HMAC key silently fell back to the public
literal 'fallback-secret' when neither UNSUBSCRIBE_SECRET nor AUTH_SECRET
was set, and apps/api (which never sets either) verifies the
unauthenticated POST /v1/email/unsubscribe endpoint with it, making every
token forgeable from source.

Resolve the secret lazily and fail closed: generating a token without a
configured secret now throws. Documented UNSUBSCRIBE_SECRET in
apps/api/.env.example.
GH-103: findSimilarContent and findSimilarContentBatch interpolated
organizationId into the Upstash Vector metadata filter with no escaping,
so a value containing a quote could append arbitrary filter clauses
(e.g. OR organizationId GLOB "*") and dump every tenant's RAG chunks.

Allowlist the prefixed-CUID shape and fail closed before the filter
string is built, in both query paths and the sync readiness check.
Adds regression tests for the literal exploit payload.
…pload

GH-78: POST /v1/questionnaire/parse/upload/token validated the trust
access token and ran RAG auto-answering without checking
Trust.securityQuestionnaireEnabled, so an org that disabled the AI
questionnaire was still fully served by any token holder.

Check the flag after token validation and return 403 when disabled;
defaults to enabled when no Trust row exists, matching the public
overview helper's semantics.
GH-53: UpdateOrganizationDto.logo accepted any string, it was stored
verbatim, and GET /v1/organization presigned it as a raw S3 key against
the shared org-assets bucket - a cross-tenant read of any known key.

Reject logo keys not prefixed with the organization's own id on update,
refuse to presign out-of-org keys on read, and skip presigning
out-of-org logos in the app layout.
…ions

GH-54: evidence form file fields accepted any non-empty fileKey, stored
it verbatim, and the submissions read path presigned it against the
shared attachments bucket - a cross-tenant read given a leaked key.

Validate every submitted fileKey against the caller's organization
prefix on create, and skip presigning out-of-org keys when refreshing
download URLs on read (defense in depth for legacy rows).
GH-257: GET /api/tasks/[taskId]/status returned any Trigger.dev run's
output to any authenticated user with no ownership check, exposing other
tenants' AI-generated policy, questionnaire, and vendor content to
whoever learned a run id.

Tag every user-triggerable tasks.trigger call with the owning
organization id and require a matching tag before returning run data;
mismatches get the same 404 as a missing run. research-vendor runs are
tagged when an active org exists (its output is shared, not tenant
data). Adds route tests for the cross-tenant and untagged cases.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@dennisofficial

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@dennisofficial I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

3 issues found across 40 files

Confidence score: 2/5

  • In apps/api/src/evidence-forms/evidence-forms.service.ts, exportCsv can presign a file key from another tenant for legacy or tampered submissions, exposing that tenant’s object; apply the organization-prefix check before signing.
  • In apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts, the organization tag trusts caller-controlled input even though the batch uses the authenticated organization, risking incorrect cross-organization tagging; derive the tag from the created batch.
  • In apps/app/src/app/api/tasks/[taskId]/status/route.ts, organization membership is enough to read run output, bypassing custom-role permissions; enforce the corresponding API RBAC check.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/evidence-forms/evidence-forms.service.ts">

<violation number="1" location="apps/api/src/evidence-forms/evidence-forms.service.ts:251">
P1: `exportCsv` still presigns `rawValue.fileKey` without checking the organization prefix, so legacy or tampered submissions can expose another tenant’s object through CSV. Apply the same prefix check before signing there.</violation>
</file>

<file name="apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts">

<violation number="1" location="apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts:42">
P2: This tag uses caller-controlled `input.organizationId`, while `createBatch` scopes the batch to authenticated `@OrganizationId()`. Derive the tag from the created batch’s organization; otherwise a run can pass another tenant’s status-route ownership check when its ID is shared.</violation>
</file>

<file name="apps/app/src/app/api/tasks/[taskId]/status/route.ts">

<violation number="1" location="apps/app/src/app/api/tasks/[taskId]/status/route.ts:36">
P2: A matching organization tag is the only authorization here, so any organization member with a run ID can read its output even when their custom role lacks the corresponding permission. Add the appropriate API RBAC check before returning run data.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic


for (const [, fileObj] of this.findFileFieldEntries(data)) {
const fileKey = fileObj.fileKey as string;
if (!fileKey.startsWith(orgPrefix)) {

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

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.

P1: exportCsv still presigns rawValue.fileKey without checking the organization prefix, so legacy or tampered submissions can expose another tenant’s object through CSV. Apply the same prefix check before signing there.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/evidence-forms/evidence-forms.service.ts, line 251:

<comment>`exportCsv` still presigns `rawValue.fileKey` without checking the organization prefix, so legacy or tampered submissions can expose another tenant’s object through CSV. Apply the same prefix check before signing there.</comment>

<file context>
@@ -214,28 +214,74 @@ export class EvidenceFormsService {
+
+    for (const [, fileObj] of this.findFileFieldEntries(data)) {
+      const fileKey = fileObj.fileKey as string;
+      if (!fileKey.startsWith(orgPrefix)) {
+        throw new BadRequestException(
+          'Submitted file does not belong to this organization',
</file context>
Fix with cubic

organizationId: input.organizationId,
connectionId: input.connectionId,
},
{ tags: [input.organizationId] },

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

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: This tag uses caller-controlled input.organizationId, while createBatch scopes the batch to authenticated @OrganizationId(). Derive the tag from the created batch’s organization; otherwise a run can pass another tenant’s status-route ownership check when its ID is shared.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/integrations/[slug]/actions/batch-fix.ts, line 42:

<comment>This tag uses caller-controlled `input.organizationId`, while `createBatch` scopes the batch to authenticated `@OrganizationId()`. Derive the tag from the created batch’s organization; otherwise a run can pass another tenant’s status-route ownership check when its ID is shared.</comment>

<file context>
@@ -32,11 +32,15 @@ export async function startBatchFix(
+        organizationId: input.organizationId,
+        connectionId: input.connectionId,
+      },
+      { tags: [input.organizationId] },
+    );
 
</file context>
Fix with cubic

// owning organization's id. A run with no matching tag either belongs to
// another tenant or was never tagged, so it's not ours to read — return
// the same 404 as a genuinely missing run to avoid leaking existence.
if (!run.tags.includes(session.session.activeOrganizationId)) {

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 2026 •

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: A matching organization tag is the only authorization here, so any organization member with a run ID can read its output even when their custom role lacks the corresponding permission. Add the appropriate API RBAC check before returning run data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/api/tasks/[taskId]/status/route.ts, line 36:

<comment>A matching organization tag is the only authorization here, so any organization member with a run ID can read its output even when their custom role lacks the corresponding permission. Add the appropriate API RBAC check before returning run data.</comment>

<file context>
@@ -29,6 +29,14 @@ export async function GET(
+    // owning organization's id. A run with no matching tag either belongs to
+    // another tenant or was never tagged, so it's not ours to read — return
+    // the same 404 as a genuinely missing run to avoid leaking existence.
+    if (!run.tags.includes(session.session.activeOrganizationId)) {
+      return NextResponse.json({ error: 'Run not found' }, { status: 404 });
+    }
</file context>
Fix with cubic

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants