diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index f6eb84ce8..175fc2296 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -541,3 +541,6 @@ A: Put restricted-user docs at `docs-mintlify/guides/apps/authentication/restric ## Q: How should e2e tests switch to a newly created project? A: `Project.createAndSwitch` should leave `backendContext.projectKeys` set to real project API keys, not only `{ projectId, adminAccessToken }`. Internal admin access tokens are regular short-lived access tokens; keeping one in the default project context makes later server/admin requests fail with `ADMIN_ACCESS_TOKEN_EXPIRED` or validate the token against the wrong project. + +## Q: How should backend SMTP SSRF checks be rolled out? +A: Keep the real outbound SMTP policy in `apps/backend/src/private/implementation/smtp-egress-policy.ts`, export it through `apps/backend/src/private/index.ts`, and provide a simple `implementation-fallback` function for self-hosters. It should allow only SMTP ports 25, 465, 587, 2465, 2587, and 2525, reject internal IP literals or DNS resolutions, and initially run report-only from `emails-low-level.tsx` via `captureError("smtp-egress-policy-report-only", ...)` before enforcing hard failures. diff --git a/AGENTS.md b/AGENTS.md index 619547aa6..8feddfe47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,8 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - NEVER INSTALL A NEW PACKAGE (or anything else) WITHOUT EXPLICIT APPROVAL FROM THE USER. - A "development environment" is either an RDE (remote development environment; = local dashboard + prod backend) or a local emulator (local dashboard + local backend). When communicating to the user, we always say "development environment" instead of RDE or local emulator (the distinction to the user is minor, even though the implementation is quite different). - NEVER EVER return a server error with an internal server error that may contain information that the user shouldn't see. For example, never return the error message on a public API from an upstream provider without properly filtering it first. Most of the time, for internal server errors, you should just use StackAssertionError (which won't pass the message to the user), not StatusError (you almost never want to instantiate a StatusError with status 5xx). +- When adding code to the `private` part of the backend, put the actual implementation into `implementation` (if the submodule is checked out), and implement a simple fallback in `implementation-fallback` for self-hosters. `implementation.generated.ts` will automatically be generated, which you can then import from `index.ts`. (See the existing code as an example.) If the submodule isn't checked out, but you need to add code to the `private` part of the backend, let the user know. +- Security-sensitive code on the backend that shouldn't be public should be in the `private` part of the backend. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx index 90648ed32..3353a7929 100644 --- a/apps/backend/src/lib/emails-low-level.tsx +++ b/apps/backend/src/lib/emails-low-level.tsx @@ -10,6 +10,7 @@ import { runAsynchronously, wait } from '@stackframe/stack-shared/dist/utils/pro import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry'; import nodemailer from 'nodemailer'; +import { checkSmtpEgressPolicy } from '@/private'; export function isSecureEmailPort(port: number | string) { // "secure" in most SMTP clients means implicit TLS from byte 1 (SMTPS) @@ -76,10 +77,27 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption return await traceSpan('sending email to ' + JSON.stringify(toArray), async () => { try { + const smtpEgressPolicyResult = await checkSmtpEgressPolicy({ + host: options.emailConfig.host, + port: options.emailConfig.port, + }); + if (smtpEgressPolicyResult.status === "error") { + console.warn("SMTP config rejected by the egress policy.", { + violation: smtpEgressPolicyResult.violation, + config: strippedEmailConfig, + }); + captureError("smtp-egress-policy-report-only", new StackAssertionError("SMTP config would be rejected by the egress policy", { + violation: smtpEgressPolicyResult.violation, + config: strippedEmailConfig, + })); + } + const transporter = nodemailer.createTransport({ host: options.emailConfig.host, port: options.emailConfig.port, secure: options.emailConfig.secure, + disableFileAccess: true, + disableUrlAccess: true, connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000, diff --git a/apps/backend/src/private/implementation b/apps/backend/src/private/implementation index b05bcca34..ebe0d435e 160000 --- a/apps/backend/src/private/implementation +++ b/apps/backend/src/private/implementation @@ -1 +1 @@ -Subproject commit b05bcca3444c00fa6623f7eec332376031aefd2c +Subproject commit ebe0d435e7bbe0251463c78d088d10edcb7ff5a0 diff --git a/apps/backend/src/private/implementation-fallback/index.ts b/apps/backend/src/private/implementation-fallback/index.ts index 69c6ddec9..0e5f42972 100644 --- a/apps/backend/src/private/implementation-fallback/index.ts +++ b/apps/backend/src/private/implementation-fallback/index.ts @@ -12,3 +12,13 @@ export const signUpRiskEngine: SignUpRiskEngine = { }; export const preprocessProxyBody: AiProxyBodyProcessor = ({ parsedBody }) => parsedBody; + +export async function checkSmtpEgressPolicy(options: { + host: string, + port: number, +}) { + return { + status: "ok" as const, + addresses: [options.host], + }; +} diff --git a/apps/backend/src/private/index.ts b/apps/backend/src/private/index.ts index 159e7607c..d565f28d1 100644 --- a/apps/backend/src/private/index.ts +++ b/apps/backend/src/private/index.ts @@ -1 +1 @@ -export { signUpRiskEngine, preprocessProxyBody } from "./implementation.generated"; +export { signUpRiskEngine, preprocessProxyBody, checkSmtpEgressPolicy } from "./implementation.generated";