Missing OAuth state validation allows login-CSRF via the gitify:// callback · Issue #3425 · gitify-app/gitify · GitHub
Skip to content

Missing OAuth state validation allows login-CSRF via the gitify:// callback #3425

Closed

Description

@carfeii

Affected versions: confirmed on commit 339aee39ed899f9993e08dd1bd48392494f8b889 (current default branch, main).

Summary

The OAuth App web-flow login never generates or validates an OAuth state parameter, so an authorization code arriving through the app's gitify://oauth custom protocol callback is accepted and exchanged for an access token regardless of where it came from, including a code an attacker obtained for their own GitHub account.

Details

src/renderer/utils/forges/github/flows.ts, performGitHubWebOAuth():

export function performGitHubWebOAuth(authOptions: LoginOAuthWebOptions): Promise<AuthResponse> {
  return new Promise((resolve, reject) => {
    const { url } = getWebFlowAuthorizationUrl({
      clientType: 'oauth-app',
      clientId: authOptions.clientId,
      scopes: getRecommendedScopeNames(),
      allowSignup: false,
      request: request.defaults({
        baseUrl: `https://${authOptions.hostname}`,
      }),
    });

    openExternalLink(url as Link);

    const handleCallback = (callbackUrl: string) => {
      let url: URL;
      try {
        url = new URL(callbackUrl);
      } catch {
        unsubscribeAuthCallback?.();
        reject(new Error(`Received an invalid authentication callback URL: ${callbackUrl}`));
        return;
      }

      const type = url.hostname;
      const code = url.searchParams.get('code');
      const error = url.searchParams.get('error');
      ...

      if (code && type === 'oauth') {
        unsubscribeAuthCallback?.();
        resolve({
          authMethod: 'OAuth App',
          authCode: code as AuthCode,
          authOptions: authOptions,
        });
      } else if (error) {
        ...
      }
    };
    ...
  });
}

No state option is passed to getWebFlowAuthorizationUrl, and handleCallback never reads or compares a state value from the callback URL. The dependency @octokit/oauth-authorization-url happens to append its own Math.random()-derived state to the authorization URL as a fallback when none is supplied, but since Gitify never stores or checks that value, it has no security effect; it is functionally inert.

src/main/index.ts registers a gitify:// custom protocol handler:

const protocol = isDevMode() ? 'gitify-dev' : 'gitify';
app.setAsDefaultProtocolClient(protocol);
...
app.on('open-url', (event, url) => {
  event.preventDefault();
  handleProtocolURL(mb, url, protocol);
});

src/main/lifecycle/startup.ts, handleProtocolURL, forwards any incoming URL starting with the protocol prefix to the renderer with no further validation:

export function handleProtocolURL(mb: Menubar, url: string, protocol: string): void {
  if (url.startsWith(`${protocol}://`)) {
    logInfo('main:handleUrl', `forwarding URL ${url} to renderer process`);
    sendRendererEvent(mb, EVENTS.AUTH_CALLBACK, url);
  }
}

Custom protocol handlers are registered at the OS level and can be triggered from a webpage, an email client, or another local application, not only from Gitify's own OAuth popup.

Steps To Reproduce

(available upon request)

Impact

An attacker completes GitHub's OAuth authorize step for their own GitHub account using Gitify's public OAuth client ID, obtaining a valid authorization code. The attacker then gets that code delivered to a victim's running Gitify instance via a crafted gitify://oauth?code=<attacker's code>&type=oauth link. The victim's Gitify client accepts the code and exchanges it for an access token, silently authenticating the victim's client as the attacker's GitHub account instead of the victim's own, with no indication to the victim that the account identity has been swapped.

This is a login-CSRF / account-binding confusion bug, not theft of the victim's own credentials and not remote code execution. No secret belonging to the victim is exposed, but the victim's client ends up bound to and displaying data from an attacker-controlled account, which could support phishing or spoofed-notification scenarios, or mislead a victim into treating attacker-controlled content as their own.

CVSS 3.1: AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N (approximately 5.4, Medium)

CWE-352 (Cross-Site Request Forgery), secondary CWE-346 (Origin Validation Error)

A fix PR is attached: generates a per-attempt random state value, passes it through to the authorization URL, and requires the callback's state to match before treating a code as belonging to that login attempt.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions