Skip to content

Security: GitHub OAuth access token transmitted to Inngest and persisted in third-party event storage #204

@anshul23102

Description

@anshul23102

Description

In src/app/actions/profile.ts, the bootstrapProfile server action sends the user's live GitHub OAuth provider_token as a field in an Inngest event payload. Inngest stores event payloads in its own cloud infrastructure. This means every signing user's GitHub access token is persisted in a third-party service outside the application's control.

Affected File and Lines

src/app/actions/profile.ts -- bootstrapProfile function, Inngest send call

Buggy Code

// src/app/actions/profile.ts
const providerToken = (await sb.auth.getSession()).data.session?.provider_token;
if (providerToken) {
  await inngest.send({
    name: 'audit/run',
    data: {
      userId: profile.id,
      githubHandle: profile.github_handle,
      githubId,
      accessToken: providerToken,  // live OAuth token placed in event payload
    },
  });
  auditQueued = true;
}

The audit/run Inngest function then reads this token:

// src/inngest/functions/audit-run.ts
type AuditEvent = {
  data: {
    userId: string;
    githubHandle: string;
    githubId: string;
    accessToken?: string;   // token stored in Inngest event log
    installationId?: number;
  };
};

Impact

1. Token persisted in Inngest's cloud storage: Inngest retains event payloads for replay, debugging, and retry purposes. The full OAuth token for every user who signs up is visible in the Inngest dashboard event log and stored in Inngest's infrastructure indefinitely.

2. Third-party breach exposure: A compromise of the Inngest account or Inngest's own infrastructure exposes the GitHub OAuth tokens of every MergeShip user, granting attackers read access to their private repositories, organisation memberships, and the ability to act as each user on GitHub.

3. Token logging: Any structured logging that captures Inngest event data (error reporting, observability tools) will capture the raw token.

4. Retry window: Inngest retries failed functions up to retries times, extending the window during which the token appears in execution context and logs.

Proposed Fix

Do not pass the OAuth token through Inngest. Instead, retrieve a GitHub App installation token on demand inside the audit function, using the installation ID (which is safe to transmit):

// bootstrapProfile -- pass installationId instead of accessToken
await inngest.send({
  name: 'audit/run',
  data: {
    userId: profile.id,
    githubHandle: profile.github_handle,
    githubId,
    // No accessToken here -- audit-run already supports installationId fallback
    installationId: userInstallationId ?? undefined,
  },
});

The audit-run function already has an installationId fallback path that mints a short-lived installation token from the GitHub App JWT. Use that path exclusively. For the bootstrap case where no installation ID is available yet, defer the audit until the installation webhook fires (which already includes the installation ID).

If a user token is strictly required for a specific API call, retrieve it from Supabase Auth within the Inngest function using the userId and the service role, rather than transmitting it through the event bus.

Severity

Critical -- every user's live GitHub OAuth token is stored in a third-party service. A single Inngest account compromise exposes all tokens. This is a systemic secrets leakage affecting 100% of users who complete signup.

I would like to work on this issue, contributing under NSoC'26. Please assign it to me.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions