diff --git a/docker-compose/deploy/.env.example b/docker-compose/deploy/.env.example
index 8f7665cc60..64f4e14f94 100644
--- a/docker-compose/deploy/.env.example
+++ b/docker-compose/deploy/.env.example
@@ -19,9 +19,10 @@ LOBE_PORT=3210
RUSTFS_PORT=9000
RUSTFS_ADMIN_PORT=9001
APP_URL=http://localhost:3210
-# INTERNAL_APP_URL is optional, used for server-to-server calls
-# to bypass CDN/proxy. If not set, defaults to APP_URL.
-# Example: INTERNAL_APP_URL=http://localhost:3210
+# INTERNAL_APP_URL is used for internal server-to-server communication.
+# Required for Docker Compose deployments, otherwise features like
+# AI image generation will fail when APP_URL is a host/LAN IP.
+INTERNAL_APP_URL=http://localhost:3210
# Secrets (auto-generated by setup.sh)
KEY_VAULTS_SECRET=YOUR_KEY_VAULTS_SECRET
diff --git a/docker-compose/deploy/.env.zh-CN.example b/docker-compose/deploy/.env.zh-CN.example
index 2c78c74c61..9075193526 100644
--- a/docker-compose/deploy/.env.zh-CN.example
+++ b/docker-compose/deploy/.env.zh-CN.example
@@ -17,9 +17,9 @@
# 如没有特殊需要不用更改
LOBE_PORT=3210
APP_URL=http://localhost:3210
-# 内部应用URL是可选的,用于服务器内部调用
-# 如果没有设置,默认使用 APP_URL
-# INTERNAL_APP_URL=http://localhost:3210
+# 内部应用URL,用于容器内部服务间通信
+# Docker Compose 部署时必须配置,否则当 APP_URL 为宿主机 IP 时 AI 生图等功能会失败
+INTERNAL_APP_URL=http://localhost:3210
# 密钥配置(由 setup.sh 自动生成)
KEY_VAULTS_SECRET=YOUR_KEY_VAULTS_SECRET
diff --git a/docker-compose/deploy/docker-compose.yml b/docker-compose/deploy/docker-compose.yml
index ad4c90307c..3d994c52f7 100644
--- a/docker-compose/deploy/docker-compose.yml
+++ b/docker-compose/deploy/docker-compose.yml
@@ -18,6 +18,7 @@ services:
- 'KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET}'
- 'AUTH_SECRET=${AUTH_SECRET}'
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
+ - 'INTERNAL_APP_URL=http://localhost:3210'
- 'S3_ENDPOINT=${S3_ENDPOINT}'
- 'S3_BUCKET=${RUSTFS_LOBE_BUCKET}'
- 'S3_ENABLE_PATH_STYLE=1'
diff --git a/docs/self-hosting/platform/docker-compose.mdx b/docs/self-hosting/platform/docker-compose.mdx
index f1052e4dde..d246d41256 100644
--- a/docs/self-hosting/platform/docker-compose.mdx
+++ b/docs/self-hosting/platform/docker-compose.mdx
@@ -308,6 +308,23 @@ services:
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
```
+3. Internal Communication URL (`INTERNAL_APP_URL`)
+
+
+ `INTERNAL_APP_URL` is **required** for Docker Compose deployments. LobeHub's async features (e.g., AI image generation) require the container to make HTTP requests to itself internally. If `INTERNAL_APP_URL` is not set, the system falls back to `APP_URL`. When `APP_URL` is a host IP (e.g., `http://10.1.7.146:8080`), the container cannot reach that address internally, causing async features like image generation to silently fail.
+
+
+```env
+INTERNAL_APP_URL=http://localhost:3210
+```
+
+- `APP_URL`: Used for browser/client access, OAuth callbacks, webhooks, etc.
+- `INTERNAL_APP_URL`: Used for internal server-to-server communication within the container (bypasses CDN/proxy/host network)
+
+
+ For Docker Compose deployments, we recommend using `http://localhost:3210` (container calling itself) or `http://lobe:3210` (using service name for container-to-container communication).
+
+
## FAQ
#### Database Migration Issues
@@ -330,37 +347,6 @@ sudo rm -rf ./data # Remove mounted database data
docker compose up -d # Restart
```
-#### Using `INTERNAL_APP_URL` for Internal Server Communication
-
-
- If you're deploying LobeHub behind a CDN (like Cloudflare) or reverse proxy, you may want to configure internal server-to-server communication to bypass the CDN/proxy layer for better performance.
-
-
-You can configure the `INTERNAL_APP_URL` environment variable:
-
-```yaml
-environment:
- - 'APP_URL=https://lobe.example.com' # Public URL for browser access
- - 'INTERNAL_APP_URL=http://localhost:3210' # Internal URL for server-to-server calls
-```
-
-**How it works:**
-
-- `APP_URL`: Used for browser/client access, OAuth callbacks, webhooks, etc. (goes through CDN/proxy)
-- `INTERNAL_APP_URL`: Used for internal server-to-server communication (bypasses CDN/proxy)
-
-If `INTERNAL_APP_URL` is not set, it defaults to `APP_URL`.
-
-**Configuration options:**
-
-- `http://localhost:3210` - If using Docker with host network mode
-- `http://lobe:3210` - If using Docker network with service name
-- `http://127.0.0.1:3210` - Alternative localhost address
-
-
- For Docker Compose deployments, we recommend using `http://lobe:3210` as the `INTERNAL_APP_URL` (using service name for container-to-container communication).
-
-
## Reverse Proxy Configuration
For production deployments with a custom domain and HTTPS, configure a reverse proxy in front of LobeHub.
diff --git a/docs/self-hosting/platform/docker-compose.zh-CN.mdx b/docs/self-hosting/platform/docker-compose.zh-CN.mdx
index 08130f8da1..1e7c5e8dfe 100644
--- a/docs/self-hosting/platform/docker-compose.zh-CN.mdx
+++ b/docs/self-hosting/platform/docker-compose.zh-CN.mdx
@@ -302,6 +302,23 @@ services:
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
```
+3. 内部通信地址 (`INTERNAL_APP_URL`)
+
+
+ Docker Compose 部署时**必须配置** `INTERNAL_APP_URL`。LobeHub 的异步功能(如 AI 生图)需要容器内部发起 HTTP 请求调用自身。如果未设置 `INTERNAL_APP_URL`,系统将使用 `APP_URL` 作为回退。当 `APP_URL` 是宿主机 IP(如 `http://10.1.7.146:8080`)时,容器内部无法访问该地址,导致生图等异步功能静默失败。
+
+
+```env
+INTERNAL_APP_URL=http://localhost:3210
+```
+
+- `APP_URL`:用于浏览器 / 客户端访问、OAuth 回调、webhook 等
+- `INTERNAL_APP_URL`:用于容器内部服务间通信(绕过 CDN / 代理 / 宿主机网络)
+
+
+ 对于 Docker Compose 部署,推荐使用 `http://localhost:3210`(容器调用自身)或 `http://lobe:3210`(使用服务名称进行容器间通信)。
+
+
## 常见问题
#### 数据库迁移问题
@@ -324,37 +341,6 @@ sudo rm -rf ./data # 移除挂载的数据库数据
docker compose up -d # 重新启动
```
-#### 使用 `INTERNAL_APP_URL` 配置内部服务器通信
-
-
- 如果你在 CDN(如 Cloudflare)或反向代理后部署 LobeHub,你可以配置内部服务器到服务器通信以绕过 CDN / 代理层,以获得更好的性能。
-
-
-你可以配置 `INTERNAL_APP_URL` 环境变量:
-
-```yaml
-environment:
- - 'APP_URL=https://lobe.example.com' # 浏览器访问的公开 URL
- - 'INTERNAL_APP_URL=http://localhost:3210' # 服务器到服务器调用的内部 URL
-```
-
-**工作原理:**
-
-- `APP_URL`:用于浏览器 / 客户端访问、OAuth 回调、webhook 等(通过 CDN / 代理)
-- `INTERNAL_APP_URL`:用于内部服务器到服务器通信(绕过 CDN / 代理)
-
-如果未设置 `INTERNAL_APP_URL`,它将默认为 `APP_URL`。
-
-**配置选项:**
-
-- `http://localhost:3210` - 如果使用 Docker 主机网络模式
-- `http://lobe:3210` - 如果使用 Docker 网络与服务名称
-- `http://127.0.0.1:3210` - 备用本地主机地址
-
-
- 对于 Docker Compose 部署,推荐使用 `http://lobe:3210` 作为 `INTERNAL_APP_URL`(使用服务名称进行容器间通信)。
-
-
## 反向代理配置
在生产环境中,如需使用自定义域名和 HTTPS,需在 LobeHub 前面配置反向代理。
diff --git a/packages/ssrf-safe-fetch/index.test.ts b/packages/ssrf-safe-fetch/index.test.ts
index 21d8828b69..50bea302cf 100644
--- a/packages/ssrf-safe-fetch/index.test.ts
+++ b/packages/ssrf-safe-fetch/index.test.ts
@@ -96,9 +96,9 @@ describe('ssrfSafeFetch', () => {
throw new Error('getaddrinfo ENOTFOUND');
});
- await expect(ssrfSafeFetch(url)).rejects.toThrow(/SSRF-safe fetch failed/);
+ await expect(ssrfSafeFetch(url)).rejects.toThrow(/Fetch failed/);
- expect(console.error).toHaveBeenCalledWith('SSRF-safe fetch error:', expect.any(Error));
+ expect(console.error).toHaveBeenCalledWith('Fetch error:', expect.any(Error));
});
});
@@ -128,21 +128,36 @@ describe('ssrfSafeFetch', () => {
});
describe('SSRF protection for malicious URLs', () => {
- const maliciousUrls = [
+ const privateHttpUrls = [
'http://169.254.169.254/latest/meta-data/', // AWS metadata service
'http://169.254.169.254:80/computeMetadata/v1/', // GCP metadata
'http://metadata.google.internal/computeMetadata/v1/',
+ ];
+
+ privateHttpUrls.forEach((url) => {
+ it(`should SSRF-block private HTTP URL: ${url}`, async () => {
+ mockFetch.mockImplementation(() => {
+ throw new Error(
+ 'DNS lookup 169.254.169.254 is not allowed. Because, It is private IP address.',
+ );
+ });
+
+ await expect(ssrfSafeFetch(url)).rejects.toThrow(/SSRF blocked/);
+ });
+ });
+
+ const unsupportedSchemeUrls = [
'file:///etc/passwd', // File protocol
'ftp://internal.company.com/secrets', // FTP protocol
];
- maliciousUrls.forEach((url) => {
- it(`should block malicious URL: ${url}`, async () => {
+ unsupportedSchemeUrls.forEach((url) => {
+ it(`should reject unsupported scheme: ${url}`, async () => {
mockFetch.mockImplementation(() => {
- throw new Error('Request blocked by SSRF protection');
+ throw new TypeError('Only HTTP(S) protocols are supported');
});
- await expect(ssrfSafeFetch(url)).rejects.toThrow(/SSRF-safe fetch failed/);
+ await expect(ssrfSafeFetch(url)).rejects.toThrow(/Fetch failed/);
});
});
});
@@ -155,9 +170,7 @@ describe('ssrfSafeFetch', () => {
throw new Error('getaddrinfo ENOTFOUND');
});
- await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow(
- /SSRF-safe fetch failed/,
- );
+ await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow(/Fetch failed/);
});
it('should handle invalid environment variable values gracefully', async () => {
@@ -168,9 +181,7 @@ describe('ssrfSafeFetch', () => {
});
// Should default to false when env var is not 'true'
- await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow(
- /SSRF-safe fetch failed/,
- );
+ await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow(/Fetch failed/);
});
});
@@ -180,10 +191,21 @@ describe('ssrfSafeFetch', () => {
mockFetch.mockRejectedValue(originalError);
await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow(
- 'SSRF-safe fetch failed: Network error',
+ 'Fetch failed: Network error',
);
- expect(console.error).toHaveBeenCalledWith('SSRF-safe fetch error:', originalError);
+ expect(console.error).toHaveBeenCalledWith('Fetch error:', originalError);
+ });
+
+ it('should throw SSRF blocked error when request-filtering-agent blocks', async () => {
+ const ssrfError = new Error(
+ 'DNS lookup 10.0.0.1(family:4, host:10.0.0.1) is not allowed. Because, It is private IP address.',
+ );
+ mockFetch.mockRejectedValue(ssrfError);
+
+ await expect(ssrfSafeFetch('http://10.0.0.1/internal')).rejects.toThrow(/SSRF blocked/);
+
+ expect(console.error).toHaveBeenCalledWith('SSRF protection blocked request:', ssrfError);
});
it('should handle non-Error thrown values', async () => {
@@ -191,16 +213,14 @@ describe('ssrfSafeFetch', () => {
mockFetch.mockRejectedValue(nonErrorValue);
await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow(
- 'SSRF-safe fetch failed: String error',
+ 'Fetch failed: String error',
);
});
it('should handle null/undefined error values', async () => {
mockFetch.mockRejectedValue(null);
- await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow(
- 'SSRF-safe fetch failed: null',
- );
+ await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow('Fetch failed: null');
});
});
diff --git a/packages/ssrf-safe-fetch/index.ts b/packages/ssrf-safe-fetch/index.ts
index 5a9871acfc..9e30836c7a 100644
--- a/packages/ssrf-safe-fetch/index.ts
+++ b/packages/ssrf-safe-fetch/index.ts
@@ -61,10 +61,20 @@ export const ssrfSafeFetch = async (
statusText: response.statusText,
});
} catch (error) {
- console.error('SSRF-safe fetch error:', error);
- throw new Error(
- `SSRF-safe fetch failed: ${error instanceof Error ? error.message : String(error)}. ` +
- 'See: https://lobehub.com/docs/self-hosting/environment-variables/basic#ssrf-allow-private-ip-address',
- );
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ // request-filtering-agent errors contain "is not allowed" when blocking private/denied IPs
+ const isSSRFBlock = errorMessage.includes('is not allowed');
+
+ if (isSSRFBlock) {
+ console.error('SSRF protection blocked request:', error);
+ throw new Error(
+ `SSRF blocked: ${errorMessage}. ` +
+ 'See: https://lobehub.com/docs/self-hosting/environment-variables/basic#ssrf-allow-private-ip-address',
+ { cause: error },
+ );
+ }
+
+ console.error('Fetch error:', error);
+ throw new Error(`Fetch failed: ${errorMessage}`, { cause: error });
}
};