CVE-2022-35924 (published 2022-08-02T18:15Z)

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.

CVSS v3 base score: 9.1, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: 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.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/nextauthjs/next-auth
commit_details: repo_url: https://github.com/nextauthjs/next-auth
author: Balázs Orbán
author_date: 2022-08-01 13:42:41+02:00
msg: 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>
repository: repo_url: https://github.com/nextauthjs/next-auth
repo_name: nextauthjs/next-auth
description: Authentication for the Web.
repo_language: TypeScript
owner: nextauthjs
file_changes: filename: email.md
change_type: ModificationType.MODIFY
diff: @@ -223,3 +223,31 @@ providers: [
   })
 ],
 ```
+
+## 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.
+:::
\ No newline at end of file

programming_language: Markdown
filename: jest.client.config.js
change_type: ModificationType.DELETE
diff: @@ -1,16 +0,0 @@
-/** @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",
-  ],
-}

programming_language: unknown
filename: jest.config.js
change_type: ModificationType.ADD
diff: @@ -0,0 +1,34 @@
+/** @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)"],
+}

programming_language: JavaScript
filename: jest.core.config.js
change_type: ModificationType.DELETE
diff: @@ -1,13 +0,0 @@
-/** @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",
-  ],
-}

programming_language: unknown
filename: package.json
change_type: ModificationType.MODIFY
diff: @@ -42,9 +42,7 @@
     "build:js": "pnpm clean && pnpm generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"",
     "build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js",
     "watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .",
-    "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",
+    "test": "jest --config ./config/jest.config.js",
     "prepublishOnly": "pnpm build",
     "generate-providers": "node ./config/generate-providers.js",
     "setup": "pnpm generate-providers",

programming_language: JavaScript
filename: signin.ts
change_type: ModificationType.MODIFY
diff: @@ -21,27 +21,28 @@ export default async function email(
     Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
   )
 
-  // Save in database
-  // @ts-expect-error
-  await adapter.createVerificationToken({
-    identifier,
-    token: hashToken(token, options),
-    expires,
-  })
-
   // Generate a link with email, unhashed token and callback url
   const params = new URLSearchParams({ callbackUrl, token, email: identifier })
   const _url = `${url}/callback/${provider.id}?${params}`
 
-  // Send to user
-  await provider.sendVerificationRequest({
-    identifier,
-    token,
-    expires,
-    url: _url,
-    provider,
-    theme,
-  })
+  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,
+    }),
+  ])
 
   return `${url}/verify-request?${new URLSearchParams({
     provider: provider.id,

programming_language: TypeScript
filename: signin.ts
change_type: ModificationType.MODIFY
diff: @@ -33,16 +33,26 @@ export default async function signin(params: {
       return { redirect: `${url}/error?error=OAuthSignin` }
     }
   } else if (provider.type === "email") {
-    /**
-     * @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()
-
+    let email: string = body?.email
     if (!email) return { redirect: `${url}/error?error=EmailSignin` }
+    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` }
+    }
 
     // Verified in `assertConfig`
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -85,10 +95,7 @@ export default async function signin(params: {
       const redirect = await emailSignin(email, options)
       return { redirect }
     } catch (error) {
-      logger.error("SIGNIN_EMAIL_ERROR", {
-        error: error as Error,
-        providerId: provider.id,
-      })
+      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
       return { redirect: `${url}/error?error=EmailSignin` }
     }
   }

programming_language: TypeScript
filename: email.ts
change_type: ModificationType.MODIFY
diff: @@ -46,6 +46,21 @@ export interface EmailConfig extends CommonProviderOptions {
   generateVerificationToken?: () => Awaitable<string>
   /** If defined, it is used to hash the verification token when saving to the database . */
   secret?: string
+  /**
+   * 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
   options: EmailUserConfig
 }
 
@@ -79,7 +94,7 @@ export default function Email(options: EmailUserConfig): EmailConfig {
       })
       const failed = result.rejected.concat(result.pending).filter(Boolean)
       if (failed.length) {
-        throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
+        throw new Error(`Email (${failed.join(", ")}) could not be sent`)
       }
     },
     options,

programming_language: TypeScript
filename: email.test.ts
change_type: ModificationType.ADD
diff: @@ -0,0 +1,167 @@
+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"
+  )
+})

programming_language: TypeScript
filename: getServerSession.test.ts
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,3 @@
-import type { NextApiRequest } from "next"
 import { MissingSecret } from "../src/core/errors"
 import { unstable_getServerSession } from "../src/next"
 import { mockLogger } from "./lib"

programming_language: TypeScript
filename: lib.ts
change_type: ModificationType.MODIFY
diff: @@ -1,6 +1,7 @@
 import { createHash } from "crypto"
-import type { LoggerInstance, NextAuthOptions } from "../src"
 import { NextAuthHandler } from "../src/core"
+import type { LoggerInstance, NextAuthOptions } from "../src"
+import type { Adapter } from "../src/adapters"
 
 export const mockLogger: () => LoggerInstance = () => ({
   error: jest.fn(() => {}),
@@ -56,3 +57,10 @@ export function createCSRF() {
     csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` },
   }
 }
+
+export function mockAdapter(): Adapter {
+  return {
+    createVerificationToken: jest.fn(() => {}),
+    getUserByEmail: jest.fn(() => {}),
+  } as Adapter
+}

programming_language: TypeScript
filename: tsconfig.json
change_type: ModificationType.MODIFY
diff: @@ -19,5 +19,12 @@
       "next": ["node_modules/next"]
     }
   },
-  "exclude": ["./*.js", "./*.d.ts", "config", "**/__tests__", "tests"]
+  "exclude": [
+    "./*.js",
+    "./*.d.ts",
+    "config",
+    "**/__tests__",
+    "tests",
+    "coverage"
+  ]
 }

programming_language: JavaScript