{"record":{"id":"46dc0982afc20960","repo":"toeverything/AFFiNE","slug":"action-forbidden-46dc09","errorCode":"action_forbidden","errorMessage":"You are not allowed to perform this action.","messagePattern":"You are not allowed to perform this action\\.","errorType":"exception","errorClass":"ActionForbidden","httpStatus":403,"severity":"error","filePath":"packages/backend/server/src/core/auth/magic-link.ts","lineNumber":42,"sourceCode":"\n  constructor(\n    private readonly url: URLHelper,\n    private readonly auth: AuthService,\n    private readonly models: Models,\n    private readonly config: Config,\n    private readonly crypto: CryptoHelper\n  ) {}\n\n  async send(\n    email: string,\n    callbackUrl = '/magic-link',\n    clientNonce?: string,\n    metadata?: Pick<MailDeliveryMetadata, 'source'>\n  ) {\n    validators.assertValidEmail(email);\n\n    if (!this.url.isAllowedCallbackUrl(callbackUrl)) {\n      throw new ActionForbidden();\n    }\n\n    const callbackUrlObj = this.url.url(callbackUrl);\n    const redirectUriInCallback =\n      callbackUrlObj.searchParams.get('redirect_uri');\n    if (\n      redirectUriInCallback &&\n      !this.url.isAllowedRedirectUri(redirectUriInCallback)\n    ) {\n      throw new ActionForbidden();\n    }\n\n    const user = await this.models.user.getUserByEmail(email, {\n      withDisabled: true,\n    });\n\n    if (!user) {\n      await this.assertSignupAllowed(email);","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/auth/magic-link.ts#L24-L60","documentation":"MagicLinkService.send validates the callbackUrl argument against UrlService.isAllowedCallbackUrl before sending a sign-in email; URLs not on the server-configured allowlist throw ActionForbidden (action_forbidden). The callback URL is where the user lands after clicking the magic link, and the allowlist exists to stop open-redirect / phishing abuse of the mail flow.","triggerScenarios":"POST to the magic-link send endpoint with callbackUrl pointing at a foreign origin (https://evil.example); a self-hosted frontend origin that was never added to the server's allowed callback list; passing an absolute URL where only specific relative paths are allowed; trailing-slash or case differences that miss the allowlist match.","commonSituations":"Self-hosting on a new domain without updating allowed callback URL config; renaming/relocating the web app; staging environment pointing at production API; third-party portals trying to relay AFFiNE sign-in.","solutions":["Use the default relative callback '/magic-link' unless you specifically configured another","Add your exact frontend origin/path to the server's allowed callback URL configuration and restart","Verify the value you send matches the configured entry character-for-character (scheme, host, no trailing slash)"],"exampleFix":"// before\nawait post('/auth/magic-link/send', { email, callbackUrl: 'https://myapp.example/cb' });\n\n// after\nawait post('/auth/magic-link/send', { email, callbackUrl: '/magic-link' }); // or an origin present in the server allowlist","handlingStrategy":"validation","validationCode":"const ALLOWED_CALLBACK_URLS = ['/magic-link']; // mirror the server allowlist\nfunction isAllowedCallbackUrl(url: string): boolean {\n  return ALLOWED_CALLBACK_URLS.includes(url);\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Default to '/magic-link' and only deviate after adding the target to server config","Keep an env-driven list of allowed callbacks shared by web and server config"],"tags":["auth","magic-link","callback-url","open-redirect","allowlist"],"backgroundTag":"callback-url-not-allowed","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}