CVE-2022-24794 (JavaScript)

Express OpenID Connect is an Express JS middleware implementing sign on for Express web apps using OpenID Connect. Users of the `requiresAuth` middleware, either directly or through the default `authRequired` option, are vulnerable to an Open Redirect when the middleware is applied to a catch all route. If all routes under `example.com` are protected with the `requiresAuth` middleware, a visit to `http://example.com//google.com` will be redirected to `google.com` after login because the original url reported by the Express framework is not properly sanitized. This vulnerability affects versions prior to 2.7.2. Users are advised to upgrade. There are no known workarounds.

Weakness:
CWE-601: name: URL Redirection to Untrusted Site ('Open Redirect')
description: The web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a redirect.

Repository: https://github.com/auth0/express-openid-connect

Vulnerable code:
file_path: lib/context.js
snippet:   async login(options = {}) {
    let { config, req, res, next, transient } = weakRef(this);
    next = cb(next).once();
    const client = await getClient(config);

    // Set default returnTo value, allow passed-in options to override or use originalUrl on GET
    let returnTo = config.baseURL;
    if (options.returnTo) {
      returnTo = options.returnTo;
      debug('req.oidc.login() called with returnTo: %s', returnTo);
    } else if (req.method === 'GET' && req.originalUrl) {
      returnTo = req.originalUrl;
      debug('req.oidc.login() without returnTo, using: %s', returnTo);
    }

    options = {
      authorizationParams: {},
      returnTo,
      ...options,
    };

    // Ensure a redirect_uri, merge in configuration options, then passed-in options.
    options.authorizationParams = {
      redirect_uri: this.getRedirectUri(),
      ...config.authorizationParams,
      ...options.authorizationParams,
    };

    const stateValue = await config.getLoginState(req, options);
    if (typeof stateValue !== 'object') {
      next(new Error('Custom state value must be an object.'));
    }
    stateValue.nonce = transient.generateNonce();
    if (options.silent) {
      stateValue.attemptingSilentLogin = true;
    }

    const usePKCE = options.authorizationParams.response_type.includes('code');
    if (usePKCE) {
      debug(
        'response_type includes code, the authorization request will use PKCE'
      );
      stateValue.code_verifier = transient.generateCodeVerifier();
    }

    try {
      const validResponseTypes = ['id_token', 'code id_token', 'code'];
      assert(
        validResponseTypes.includes(options.authorizationParams.response_type),
        `response_type should be one of ${validResponseTypes.join(', ')}`
      );
      assert(
        /\bopenid\b/.test(options.authorizationParams.scope),
        'scope should contain "openid"'
      );

      const authVerification = {
        nonce: transient.generateNonce(),
        state: encodeState(stateValue),
        ...(options.authorizationParams.max_age
          ? {
              max_age: options.authorizationParams.max_age,
            }
          : undefined),
      };
      const authParams = {
        ...options.authorizationParams,
        ...authVerification,
      };

      if (usePKCE) {
        authVerification.code_verifier = transient.generateNonce();

        authParams.code_challenge_method = 'S256';
        authParams.code_challenge = transient.calculateCodeChallenge(
          authVerification.code_verifier
        );
      }

      transient.store('auth_verification', req, res, {
        sameSite:
          options.authorizationParams.response_mode === 'form_post'
            ? 'None'
            : config.transactionCookie.sameSite,
        value: JSON.stringify(authVerification),
      });

      const authorizationUrl = client.authorizationUrl(authParams);
      debug('redirecting to %s', authorizationUrl);
      res.redirect(authorizationUrl);
    } catch (err) {
      next(err);
    }
  }

Fixed code:
file_path: lib/context.js
snippet:   async login(options = {}) {
    let { config, req, res, next, transient } = weakRef(this);
    next = cb(next).once();
    const client = await getClient(config);

    // Set default returnTo value, allow passed-in options to override or use originalUrl on GET
    let returnTo = config.baseURL;
    if (options.returnTo) {
      returnTo = options.returnTo;
      debug('req.oidc.login() called with returnTo: %s', returnTo);
    } else if (req.method === 'GET' && req.originalUrl) {
      // Collapse any leading slashes to a single slash to prevent Open Redirects
      returnTo = req.originalUrl.replace(/^\/+/, '/');
      debug('req.oidc.login() without returnTo, using: %s', returnTo);
    }

    options = {
      authorizationParams: {},
      returnTo,
      ...options,
    };

    // Ensure a redirect_uri, merge in configuration options, then passed-in options.
    options.authorizationParams = {
      redirect_uri: this.getRedirectUri(),
      ...config.authorizationParams,
      ...options.authorizationParams,
    };

    const stateValue = await config.getLoginState(req, options);
    if (typeof stateValue !== 'object') {
      next(new Error('Custom state value must be an object.'));
    }
    stateValue.nonce = transient.generateNonce();
    if (options.silent) {
      stateValue.attemptingSilentLogin = true;
    }

    const usePKCE = options.authorizationParams.response_type.includes('code');
    if (usePKCE) {
      debug(
        'response_type includes code, the authorization request will use PKCE'
      );
      stateValue.code_verifier = transient.generateCodeVerifier();
    }

    try {
      const validResponseTypes = ['id_token', 'code id_token', 'code'];
      assert(
        validResponseTypes.includes(options.authorizationParams.response_type),
        `response_type should be one of ${validResponseTypes.join(', ')}`
      );
      assert(
        /\bopenid\b/.test(options.authorizationParams.scope),
        'scope should contain "openid"'
      );

      const authVerification = {
        nonce: transient.generateNonce(),
        state: encodeState(stateValue),
        ...(options.authorizationParams.max_age
          ? {
              max_age: options.authorizationParams.max_age,
            }
          : undefined),
      };
      const authParams = {
        ...options.authorizationParams,
        ...authVerification,
      };

      if (usePKCE) {
        authVerification.code_verifier = transient.generateNonce();

        authParams.code_challenge_method = 'S256';
        authParams.code_challenge = transient.calculateCodeChallenge(
          authVerification.code_verifier
        );
      }

      transient.store('auth_verification', req, res, {
        sameSite:
          options.authorizationParams.response_mode === 'form_post'
            ? 'None'
            : config.transactionCookie.sameSite,
        value: JSON.stringify(authVerification),
      });

      const authorizationUrl = client.authorizationUrl(authParams);
      debug('redirecting to %s', authorizationUrl);
      res.redirect(authorizationUrl);
    } catch (err) {
      next(err);
    }
  }