CVE-2022-35924 (TypeScript, published 2022-08-02T18:15Z)
Description: value: NextAuth.js is a complete open source authentication solution for Next.js applications. `next-auth` users who are using the `EmailProvider` either in versions before `4.10.3` or `3.29.10` are affected. If an attacker could forge a request that sent a comma-separated list of emails (eg.: `attacker@attacker.com,victim@victim.com`) to the sign-in endpoint, NextAuth.js would send emails to both the attacker and the victim's e-mail addresses. The attacker could then login as a newly created user with the email being `attacker@attacker.com,victim@victim.com`. This means that basic authorization like `email.endsWith("@victim.com")` in the `signIn` callback would fail to communicate a threat to the developer and would let the attacker bypass authorization, even with an `@attacker.com` address. This vulnerability has been patched in `v4.10.3` and `v3.29.10` by normalizing the email value that is sent to the sign-in endpoint before accessing it anywhere else. We also added a `normalizeIdentifier` callback on the `EmailProvider` configuration, where you can further tweak your requirements for what your system considers a valid e-mail address. (E.g.: strict RFC2821 compliance). Users are advised to upgrade. There are no known workarounds for this vulnerability. If for some reason you cannot upgrade, you can normalize the incoming request using Advanced Initialization.
Severity: nan (CVSS v3 base score: 9.1)
Weakness: CWE-863 \u2014 Incorrect Authorization
The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
Repository: https://github.com/nextauthjs/next-auth
Files changed:
docs/docs/providers/email.md
packages/next-auth/config/jest.config.js
packages/next-auth/package.json
packages/next-auth/src/core/lib/email/signin.ts
packages/next-auth/src/core/routes/signin.ts
packages/next-auth/src/providers/email.ts
packages/next-auth/tests/email.test.ts
packages/next-auth/tests/getServerSession.test.ts
packages/next-auth/tests/lib.ts
packages/next-auth/tsconfig.json
Commit message: fix(providers): add `normalizeIdentifier` to EmailProvider

* fix(providers): add `normalizeIdentifier` to EmailProvider

* docs: document `normalizeIdentifier`

* fix: allow throwing error from normalizer

* test: add e-mail tests

* chore: log provider id

* test: merge client+config jest configs and add coverage report

* test: show coverage for untested files

* fix: only allow first domain in email. Add tests

* chore: add `coverage` to tsconfig exclude list

* cleanup

* revert

Co-authored-by: Thang Vu <thvu@hey.com>


Vulnerable code:
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  transform: {
    "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
  },
  rootDir: "../src",
  setupFilesAfterEnv: ["../config/jest-setup.js"],
  testMatch: ["**/*.test.js"],
  // collectCoverageFrom: ["!client/__tests__/**"],
  // coverageDirectory: "../coverage",
  testEnvironment: "jsdom",
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
}
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  transform: {
    "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
  },
  rootDir: "..",
  testMatch: ["**/*.test.ts"],
  setupFilesAfterEnv: ["./config/jest-setup.js"],
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
}
    "test:client": "jest --config ./config/jest.client.config.js",
    "test:core": "jest --config ./config/jest.core.config.js",
    "test": "pnpm test:core && pnpm test:client",
  // Save in database
  // @ts-expect-error
  await adapter.createVerificationToken({
    identifier,
    token: hashToken(token, options),
    expires,
  })

  // Send to user
  await provider.sendVerificationRequest({
    identifier,
    token,
    expires,
    url: _url,
    provider,
    theme,
  })
    /**
     * @note Technically the part of the email address local mailbox element
     * (everything before the @ symbol) should be treated as 'case sensitive'
     * according to RFC 2821, but in practice this causes more problems than
     * it solves. We treat email addresses as all lower case. If anyone
     * complains about this we can make strict RFC 2821 compliance an option.
     */
    const email = body?.email?.toLowerCase()

      logger.error("SIGNIN_EMAIL_ERROR", {
        error: error as Error,
        providerId: provider.id,
      })
        throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
import type { NextApiRequest } from "next"
import type { LoggerInstance, NextAuthOptions } from "../src"
  "exclude": ["./*.js", "./*.d.ts", "config", "**/__tests__", "tests"]

Fixed code:

## Normalizing the email address

By default, NextAuth.js will normalize the email address. It treats values as case-insensitive (which is technically not compliant to the [RFC 2821 spec](https://datatracker.ietf.org/doc/html/rfc2821), but in practice this causes more problems than it solves, eg. when looking up users by e-mail from databases.) and also removes any secondary email address that was passed in as a comma-separated list. You can apply your own normalization via the `normalizeIdentifier` method on the `EmailProvider`. The following example shows the default behavior:
```ts
  EmailProvider({
    // ...
    normalizeIdentifier(identifier: string): string {
      // Get the first two elements only,
      // separated by `@` from user input.
      let [local, domain] = identifier.toLowerCase().trim().split("@")
      // The part before "@" can contain a ","
      // but we remove it on the domain part
      domain = domain.split(",")[0]
      return `${local}@${domain}`

      // You can also throw an error, which will redirect the user
      // to the error page with error=EmailSignin in the URL
      // if (identifier.split("@").length > 2) {
      //   throw new Error("Only one email allowed")
      // }
    },
  })
```

:::warning
Always make sure this returns a single e-mail address, even if multiple ones were passed in.
:::
/** @type {import('jest').Config} */
module.exports = {
  projects: [
    {
      displayName: "core",
      testMatch: ["**/*.test.ts"],
      rootDir: ".",
      setupFilesAfterEnv: ["./config/jest-setup.js"],
      transform: {
        "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
      },
      coveragePathIgnorePatterns: ["tests"],
    },
    {
      displayName: "client",
      testMatch: ["**/*.test.js"],
      setupFilesAfterEnv: ["./config/jest-setup.js"],
      rootDir: ".",
      transform: {
        "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
      },
      testEnvironment: "jsdom",
      coveragePathIgnorePatterns: ["__tests__"],
    },
  ],
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
  collectCoverage: true,
  coverageDirectory: "../coverage",
  coverageReporters: ["html", "text-summary"],
  collectCoverageFrom: ["src/**/*.(js|jsx|ts|tsx)"],
}
    "test": "jest --config ./config/jest.config.js",
  await Promise.all([
    // Send to user
    provider.sendVerificationRequest({
      identifier,
      token,
      expires,
      url: _url,
      provider,
      theme,
    }),
    // Save in database
    // @ts-expect-error // verified in `assertConfig`
    adapter.createVerificationToken({
      identifier,
      token: hashToken(token, options),
      expires,
    }),
  ])
    let email: string = body?.email
    const normalizer: (identifier: string) => string =
      provider.normalizeIdentifier ??
      ((identifier) => {
        // Get the first two elements only,
        // separated by `@` from user input.
        let [local, domain] = identifier.toLowerCase().trim().split("@")
        // The part before "@" can contain a ","
        // but we remove it on the domain part
        domain = domain.split(",")[0]
        return `${local}@${domain}`
      })

    try {
      email = normalizer(body?.email)
    } catch (error) {
      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
      return { redirect: `${url}/error?error=EmailSignin` }
    }
      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
  /**
   * Normalizes the user input before sending the verification request.
   *
   * ⚠️ Always make sure this method returns a single email address.
   *
   * @note Technically, the part of the email address local mailbox element
   * (everything before the `@` symbol) should be treated as 'case sensitive'
   * according to RFC 2821, but in practice this causes more problems than
   * it solves, e.g.: when looking up users by e-mail from databases.
   * By default, we treat email addresses as all lower case,
   * but you can override this function to change this behavior.
   *
   * [Documentation](https://next-auth.js.org/providers/email#normalizing-the-e-mail-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax)
   */
  normalizeIdentifier?: (identifier: string) => string
        throw new Error(`Email (${failed.join(", ")}) could not be sent`)
import { createCSRF, handler, mockAdapter } from "./lib"
import EmailProvider from "../src/providers/email"

it("Send e-mail to the only address correctly", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn(() => true)

  const email = "email@example.com"
  const { res } = await handler(
    {
      adapter: mockAdapter(),
      providers: [EmailProvider({ sendVerificationRequest })],
      callbacks: { signIn },
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
      },
    }
  )

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
  )

  expect(signIn).toBeCalledTimes(1)
  expect(signIn).toHaveBeenCalledWith(
    expect.objectContaining({
      user: expect.objectContaining({ email }),
    })
  )

  expect(sendVerificationRequest).toHaveBeenCalledWith(
    expect.objectContaining({ identifier: email })
  )
})

it("Send e-mail to first address only", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn(() => true)

  const firstEmail = "email@email.com"
  const email = `${firstEmail},email@email2.com`
  const { res } = await handler(
    {
      adapter: mockAdapter(),
      providers: [EmailProvider({ sendVerificationRequest })],
      callbacks: { signIn },
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
      },
    }
  )

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
  )

  expect(signIn).toBeCalledTimes(1)
  expect(signIn).toHaveBeenCalledWith(
    expect.objectContaining({
      user: expect.objectContaining({ email: firstEmail }),
    })
  )

  expect(sendVerificationRequest).toHaveBeenCalledWith(
    expect.objectContaining({ identifier: firstEmail })
  )
})

it("Send e-mail to address with first domain", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn(() => true)

  const firstEmail = "email@email.com"
  const email = `${firstEmail},email2.com`
  const { res } = await handler(
    {
      adapter: mockAdapter(),
      providers: [EmailProvider({ sendVerificationRequest })],
      callbacks: { signIn },
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
      },
    }
  )

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
  )

  expect(signIn).toBeCalledTimes(1)
  expect(signIn).toHaveBeenCalledWith(
    expect.objectContaining({
      user: expect.objectContaining({ email: firstEmail }),
    })
  )

  expect(sendVerificationRequest).toHaveBeenCalledWith(
    expect.objectContaining({ identifier: firstEmail })
  )
})

it("Redirect to error page if multiple addresses aren't allowed", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn()
  const error = new Error("Only one email allowed")
  const { res, log } = await handler(
    {
      adapter: mockAdapter(),
      callbacks: { signIn },
      providers: [
        EmailProvider({
          sendVerificationRequest,
          normalizeIdentifier(identifier) {
            if (identifier.split("@").length > 2) throw error
            return identifier
          },
        }),
      ],
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({
          email: "email@email.com,email@email2.com",
          csrfToken: csrf.value,
        }),
      },
    }
  )

  expect(signIn).toBeCalledTimes(0)
  expect(sendVerificationRequest).toBeCalledTimes(0)

  expect(log.error.mock.calls[0]).toEqual([
    "SIGNIN_EMAIL_ERROR",
    { error, providerId: "email" },
  ])

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/error?error=EmailSignin"
  )
})
import type { LoggerInstance, NextAuthOptions } from "../src"
import type { Adapter } from "../src/adapters"

export function mockAdapter(): Adapter {
  return {
    createVerificationToken: jest.fn(() => {}),
    getUserByEmail: jest.fn(() => {}),
  } as Adapter
}
  "exclude": [
    "./*.js",
    "./*.d.ts",
    "config",
    "**/__tests__",
    "tests",
    "coverage"
  ]