diff --git a/.env.example b/.env.example index 0bbb75149..afc3508e6 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,12 @@ FLAG_DISABLE_EMAIL_AUTH="false" # This is useful if you are using a machine with limited resources, like a Raspberry Pi. FLAG_DISABLE_IMAGE_PROCESSING="false" +# Allows AI providers to be configured with any base URL, including http:// and +# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance). +# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF) +# risk. Only enable this on a trusted, single-tenant self-hosted instance. +FLAG_ALLOW_UNSAFE_AI_BASE_URL="false" + # --- Others --- # Google Cloud API Key (optional) # For font-list generation tooling. diff --git a/AGENTS.md b/AGENTS.md index 6dd6dc3df..c80cebc12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,3 +104,4 @@ Vitest test paths are package-relative when running through `pnpm --filter option.value === provider); }, [provider]); - const canTestConnection = model.trim().length > 0 && apiKey.trim().length > 0 && isValidOptionalBaseURL(baseURL); + const canTestConnection = model.trim().length > 0 && apiKey.trim().length > 0; const { mutate: testConnection, isPending: isTesting } = useMutation(orpc.ai.testConnection.mutationOptions()); diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 8e7e11031..22b3241d5 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -180,7 +180,6 @@ Here's a complete list of environment variables you can configure: | `BETTER_AUTH_API_KEY` | Better Auth dashboard API key | — | | `BETTER_AUTH_URL` | Better Auth base URL override (advanced) | `APP_URL` | | `BETTER_AUTH_SECRET` | Better Auth secret override (advanced) | `AUTH_SECRET` | -| `AI_ALLOWED_BASE_URLS` | Allowlist for custom AI provider base URLs | — | | `SMTP_HOST` | SMTP Server Host (for email features) | — | | `SMTP_PORT` | SMTP Server Port | `587` | | `SMTP_USER` | SMTP Username | — | @@ -196,6 +195,7 @@ Here's a complete list of environment variables you can configure: | `FLAG_DISABLE_SIGNUPS` | Disables new user signups | `false` | | `FLAG_DISABLE_EMAIL_AUTH` | Disables email/password login (SSO only) | `false` | | `FLAG_DISABLE_IMAGE_PROCESSING` | Disables image processing | `false` | +| `FLAG_ALLOW_UNSAFE_AI_BASE_URL` | Enables local-network AI providers (e.g. Ollama) | `false` | > **Note:** Some variables are only required for using related features (OAuth, SMTP, S3, etc.) and can be left unset if unused. diff --git a/docs/self-hosting/docker.mdx b/docs/self-hosting/docker.mdx index b25194fe0..887edd47e 100644 --- a/docs/self-hosting/docker.mdx +++ b/docs/self-hosting/docker.mdx @@ -90,10 +90,6 @@ OAUTH_SCOPES="" # BETTER_AUTH_URL="https://auth.example.com" # BETTER_AUTH_SECRET="" -# --- AI (optional) --- -# Comma-separated hostnames/origins for custom AI base URLs -# Example: api.openai.com,https://gateway.ai.vercel.com -AI_ALLOWED_BASE_URLS="" # --- Email (optional) --- # If all keys are disabled, the app logs the email to be sent to the console instead. @@ -120,6 +116,7 @@ S3_FORCE_PATH_STYLE="false" FLAG_DISABLE_SIGNUPS="false" FLAG_DISABLE_EMAIL_AUTH="false" FLAG_DISABLE_IMAGE_PROCESSING="false" +FLAG_ALLOW_UNSAFE_AI_BASE_URL="false" ``` @@ -254,9 +251,6 @@ docker compose logs -f reactive-resume
  • S3 storage (S3_*)
  • -
  • - AI URL allowlist (AI_ALLOWED_BASE_URLS) -
  • Feature flags (FLAG_*)
  • @@ -337,15 +331,11 @@ openssl rand -hex 32 - `"false"` for virtual-hosted-style URLs (`https://bucket.endpoint`) common with AWS S3 / Cloudflare R2. - - - **`AI_ALLOWED_BASE_URLS`**: Comma-separated hosts or origins allowed as custom AI API base URLs. - Use this when - routing AI requests through your own gateway/proxy. - Example: `api.openai.com,https://gateway.ai.vercel.com` - - - + - **`FLAG_DISABLE_SIGNUPS`**: Disables new signups (web app and server). Useful for private instances. - **`FLAG_DISABLE_EMAIL_AUTH`**: Disables email/password login entirely. Also disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/LinkedIn/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. Useful when only SSO is required. - **`FLAG_DISABLE_IMAGE_PROCESSING`**: Disables image processing. This is useful if you are using a machine with limited resources, like a Raspberry Pi. + - **`FLAG_ALLOW_UNSAFE_AI_BASE_URL`**: Allows AI providers to be configured with `http://` URLs and private/loopback addresses (e.g. a local Ollama instance at `http://192.168.1.10:11434`). **Warning: enabling this on a multi-tenant deployment is an SSRF risk.** Only enable on a trusted, single-tenant self-hosted instance. diff --git a/packages/api/src/services/ai.ts b/packages/api/src/services/ai.ts index 0bad2219c..c09977bfd 100644 --- a/packages/api/src/services/ai.ts +++ b/packages/api/src/services/ai.ts @@ -27,6 +27,7 @@ import { resumePatchProposalToolOutputSchema, } from "@reactive-resume/ai/tools/patch-proposal"; import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types"; +import { env } from "@reactive-resume/env/server"; import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis"; import { applyResumePatches } from "@reactive-resume/utils/resume/patch"; import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security.node"; @@ -79,9 +80,12 @@ function resolveBaseUrl(input: GetModelInput): string { const parsedBaseURL = parseUrl(baseURL); if (!parsedBaseURL) throw new Error("INVALID_AI_BASE_URL"); - if (parsedBaseURL.protocol !== "https:") throw new Error("INVALID_AI_BASE_URL"); if (parsedBaseURL.username || parsedBaseURL.password) throw new Error("INVALID_AI_BASE_URL"); - if (isPrivateOrLoopbackHost(parsedBaseURL.hostname)) throw new Error("INVALID_AI_BASE_URL"); + + if (!env.FLAG_ALLOW_UNSAFE_AI_BASE_URL) { + if (parsedBaseURL.protocol !== "https:") throw new Error("INVALID_AI_BASE_URL"); + if (isPrivateOrLoopbackHost(parsedBaseURL.hostname)) throw new Error("INVALID_AI_BASE_URL"); + } return parsedBaseURL.toString(); } diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 1fe96fc2b..bea1ae3a0 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -70,6 +70,7 @@ export const env = createEnv({ FLAG_DISABLE_SIGNUPS: z.stringbool().default(false), FLAG_DISABLE_EMAIL_AUTH: z.stringbool().default(false), FLAG_DISABLE_IMAGE_PROCESSING: z.stringbool().default(false), + FLAG_ALLOW_UNSAFE_AI_BASE_URL: z.stringbool().default(false), // Crowdin (optional, for translation tooling) CROWDIN_PROJECT_ID: z.string().optional(), diff --git a/turbo.json b/turbo.json index 39dff1f25..aa59b0ff6 100644 --- a/turbo.json +++ b/turbo.json @@ -37,6 +37,7 @@ "FLAG_DISABLE_SIGNUPS", "FLAG_DISABLE_EMAIL_AUTH", "FLAG_DISABLE_IMAGE_PROCESSING", + "FLAG_ALLOW_UNSAFE_AI_BASE_URL", "GOOGLE_CLOUD_API_KEY", "CROWDIN_PROJECT_ID", "CROWDIN_API_TOKEN"