diff --git a/.env.example b/.env.example index 1d552ef..1d57429 100644 --- a/.env.example +++ b/.env.example @@ -17,10 +17,14 @@ API_BASE_URL= API_PROXY_TARGET=http://backend:8888 # Spring Boot 运行配置 +SERVER_ADDRESS=127.0.0.1 SERVER_PORT=8888 APP_AUTO_OPEN_BROWSER=false APP_BROWSER_INITIALIZE_ON_STARTUP=false APP_STATIC_SERVER_ENABLED=true + +# 原生前端生产服务默认仅监听本机;只有明确需要局域网访问时才修改。 +FRONTEND_HOST=127.0.0.1 TZ=Asia/Shanghai JAVA_OPTS=-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 diff --git "a/doc/API\346\216\245\345\217\243.md" "b/doc/API\346\216\245\345\217\243.md" index 8f511f0..0abd954 100644 --- "a/doc/API\346\216\245\345\217\243.md" +++ "b/doc/API\346\216\245\345\217\243.md" @@ -18,10 +18,11 @@ http://localhost:8888 | 方法 | 路径 | 说明 | | --- | --- | --- | -| `GET` | `/api/config` | 获取全部全局配置 | -| `GET` | `/api/config/{key}` | 获取指定配置 | -| `POST` | `/api/config` | 新增配置 | -| `PUT` | `/api/config/{key}` | 更新指定配置 | +| `GET` | `/api/config` | 获取 UI 白名单内的全局配置;敏感值只返回是否已配置 | +| `GET` | `/api/config/{key}` | 获取指定白名单配置;`API_KEY`、`HOOK_URL` 不返回原值 | +| `POST` | `/api/config` | 批量新增或更新白名单配置;敏感值为空时保留原值 | +| `PUT` | `/api/config/{key}` | 更新指定白名单配置 | +| `DELETE` | `/api/config/{key}` | 显式清除 `API_KEY` 或 `HOOK_URL` 的数据库值 | | `GET` | `/api/config/health` | 配置模块健康检查 | ## AI 配置 @@ -54,9 +55,11 @@ Boss 投递分析页“岗位数据”区域使用 `GET/POST /api/ai/thresholds` | 方法 | 路径 | 说明 | | --- | --- | --- | -| `GET` | `/api/cookie` | 获取 Cookie | +| `GET` | `/api/cookie` | 获取 Cookie 是否已配置及非敏感元数据,不返回 Cookie 原文 | | `POST` | `/api/cookie/save` | 保存 Cookie | +> Cookie 原文只供内部浏览器会话恢复使用。各平台兼容查询接口同样只返回 `configured` 状态和非敏感元数据。 + ## Profile 档案 Profile 用于区分不同候选人或简历上下文。当前档案会影响 AI 配置、简历、平台配置、岗位数据和 AI 分析结果的读取与写入。 diff --git a/doc/Dsign.md b/doc/Dsign.md index 2fd8a3c..0eca744 100644 --- a/doc/Dsign.md +++ b/doc/Dsign.md @@ -199,8 +199,9 @@ SSE 推送前端 ▶ 前端刷新任务状态 ### 6.3 敏感信息与加密 -- AI Key、Cookie 等敏感字段使用对称加密存储(进程启动时解密)。 -- 数据库文件与快照目录加入 Git 忽略;生产环境可选 SQLCipher。 +- 当前 V1 的 AI Key、Webhook 和 Cookie 保存在本机 SQLite 中,**尚未做字段级加密**。 +- HTTP 查询接口不回传敏感原文,配置页面采用只写输入并只显示“是否已配置”;内部 AI、通知和浏览器会话按需读取原值。 +- 数据库文件与快照目录必须加入 Git 忽略;对外部署或多人共享前,需要再引入系统凭据库、字段级加密或 SQLCipher,并补齐身份认证与用户隔离。 ### 6.4 资源与路径规范 diff --git a/docker-compose.yml b/docker-compose.yml index 7003917..1943b50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - "127.0.0.1:${BACKEND_PORT:-8888}:8888" environment: TZ: ${TZ:-Asia/Shanghai} + SERVER_ADDRESS: 0.0.0.0 SERVER_PORT: ${SERVER_PORT:-8888} APP_AUTO_OPEN_BROWSER: ${APP_AUTO_OPEN_BROWSER:-false} SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL:-jdbc:sqlite:/workspace/db/getjobs.db} diff --git a/front/app/env-config/page.tsx b/front/app/env-config/page.tsx index 5b94327..5b93528 100644 --- a/front/app/env-config/page.tsx +++ b/front/app/env-config/page.tsx @@ -23,6 +23,8 @@ export default function EnvConfig() { }) const [showApiKey, setShowApiKey] = useState(false) + const [sensitiveConfigured, setSensitiveConfigured] = useState({ hookUrl: false, apiKey: false }) + const [sensitiveDirty, setSensitiveDirty] = useState({ hookUrl: false, apiKey: false }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false) @@ -47,13 +49,13 @@ export default function EnvConfig() { if (result.success && result.data) { setEnvConfig({ - hookUrl: result.data.HOOK_URL || '', + hookUrl: '', aiProvider: result.data.AI_PROVIDER === 'api' || result.data.AI_PROVIDER === 'remote' ? 'api' : 'codex', codexPath: result.data.CODEX_PATH || 'codex', codexModel: result.data.CODEX_MODEL || 'gpt-5.6-sol', codexTimeoutSeconds: result.data.CODEX_TIMEOUT_SECONDS || '300', baseUrl: result.data.BASE_URL || '', - apiKey: result.data.API_KEY || '', + apiKey: '', model: result.data.MODEL || '', botIsSend: (() => { const raw = result.data.BOT_IS_SEND @@ -61,6 +63,11 @@ export default function EnvConfig() { return val === '1' || val === 'true' ? 1 : 0 })(), }) + setSensitiveConfigured({ + hookUrl: result.sensitive?.HOOK_URL === true, + apiKey: result.sensitive?.API_KEY === true, + }) + setSensitiveDirty({ hookUrl: false, apiKey: false }) } } catch (error) { console.error('获取配置失败:', error) @@ -78,17 +85,21 @@ export default function EnvConfig() { try { setSaving(true) - const configMap = { - HOOK_URL: envConfig.hookUrl, + const configMap: Record = { AI_PROVIDER: envConfig.aiProvider, CODEX_PATH: envConfig.codexPath, CODEX_MODEL: envConfig.codexModel, CODEX_TIMEOUT_SECONDS: envConfig.codexTimeoutSeconds, BASE_URL: envConfig.baseUrl, - API_KEY: envConfig.apiKey, MODEL: envConfig.model, BOT_IS_SEND: String(envConfig.botIsSend ?? 0), } + if (sensitiveDirty.hookUrl && envConfig.hookUrl.trim()) { + configMap.HOOK_URL = envConfig.hookUrl.trim() + } + if (sensitiveDirty.apiKey && envConfig.apiKey.trim()) { + configMap.API_KEY = envConfig.apiKey.trim() + } const response = await fetch(`${API_BASE}/api/config`, { method: 'POST', @@ -105,6 +116,12 @@ export default function EnvConfig() { const result = await response.json() if (result.success) { + setSensitiveConfigured((current) => ({ + hookUrl: sensitiveDirty.hookUrl && envConfig.hookUrl.trim() ? true : current.hookUrl, + apiKey: sensitiveDirty.apiKey && envConfig.apiKey.trim() ? true : current.apiKey, + })) + setSensitiveDirty({ hookUrl: false, apiKey: false }) + setEnvConfig((current) => ({ ...current, hookUrl: '', apiKey: '' })) if (!silent) { setSaveResult({ success: true, message: '保存成功' }) setShowSaveDialog(true) @@ -123,6 +140,39 @@ export default function EnvConfig() { } } + const clearSensitiveConfig = async (key: 'HOOK_URL' | 'API_KEY') => { + const label = key === 'HOOK_URL' ? 'Webhook URL' : 'API Key' + if (!window.confirm(`确定清除已保存的 ${label} 吗?清除后相关功能将无法使用,直到重新填写。`)) { + return + } + + try { + setSaving(true) + const response = await fetch(`${API_BASE}/api/config/${key}`, { method: 'DELETE' }) + const result = await response.json() + if (!response.ok || !result.success) { + throw new Error(result.message || '清除失败') + } + if (key === 'HOOK_URL') { + setSensitiveConfigured((current) => ({ ...current, hookUrl: result.configured === true })) + setSensitiveDirty((current) => ({ ...current, hookUrl: false })) + setEnvConfig((current) => ({ ...current, hookUrl: '' })) + } else { + setSensitiveConfigured((current) => ({ ...current, apiKey: result.configured === true })) + setSensitiveDirty((current) => ({ ...current, apiKey: false })) + setEnvConfig((current) => ({ ...current, apiKey: '' })) + } + setSaveResult({ success: true, message: result.message || `${label} 已清除` }) + setShowSaveDialog(true) + } catch (error) { + console.error('清除敏感配置失败:', error) + setSaveResult({ success: false, message: `${label} 清除失败,请检查后端服务。` }) + setShowSaveDialog(true) + } finally { + setSaving(false) + } + } + return (
Webhook URL setEnvConfig({ ...envConfig, hookUrl: e.target.value })} - placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key" + onChange={(e) => { + setEnvConfig({ ...envConfig, hookUrl: e.target.value }) + setSensitiveDirty({ ...sensitiveDirty, hookUrl: true }) + }} + placeholder={sensitiveConfigured.hookUrl ? '已配置;输入新值可替换' : 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key'} /> -

- 企业微信群机器人webhook地址,用于接收通知消息 -

+
+

+ {sensitiveConfigured.hookUrl ? '已配置,页面不会读取或显示原值。' : '尚未配置企业微信 Webhook。'} +

+ {sensitiveConfigured.hookUrl && ( + + )} +
@@ -273,8 +333,11 @@ export default function EnvConfig() { id="apiKey" type={showApiKey ? 'text' : 'password'} value={envConfig.apiKey} - onChange={(e) => setEnvConfig({ ...envConfig, apiKey: e.target.value })} - placeholder="sk-xxxxxxxxxxxxxxxxx" + onChange={(e) => { + setEnvConfig({ ...envConfig, apiKey: e.target.value }) + setSensitiveDirty({ ...sensitiveDirty, apiKey: true }) + }} + placeholder={sensitiveConfigured.apiKey ? '已配置;输入新值可替换' : 'sk-xxxxxxxxxxxxxxxxx'} /> -

- 🔐 API密钥将被安全存储,请妥善保管 -

+
+

+ {sensitiveConfigured.apiKey ? '已配置,页面不会读取或显示原值。' : '尚未配置远程 API Key。'} +

+ {sensitiveConfigured.apiKey && ( + + )} +
@@ -300,9 +370,8 @@ export default function EnvConfig() {

- 提示: 这些环境变量将保存到{' '} - .env{' '} - 文件中。请勿将包含敏感信息的 .env 文件提交到版本控制系统。 + 提示: 配置保存在本机项目数据库中。API Key 和 Webhook + 只允许写入,页面只显示“是否已配置”,不会读取或回显原值。

diff --git a/front/package.json b/front/package.json index 5558ed5..7f62dc8 100644 --- a/front/package.json +++ b/front/package.json @@ -7,7 +7,8 @@ "build": "next build", "build:prod": "next build && node scripts/copy-dist.mjs", "start": "node start-prod.mjs", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "dependencies": { "@radix-ui/react-label": "^2.1.7", diff --git a/front/server.config.js b/front/server.config.js index 2e90213..db198ab 100644 --- a/front/server.config.js +++ b/front/server.config.js @@ -21,7 +21,7 @@ module.exports = { // 生产环境端口 port: 6866, // 主机名 - hostname: '0.0.0.0', + hostname: process.env.FRONTEND_HOST || '127.0.0.1', }, // API 配置(如果需要在构建时使用) diff --git a/front/start-prod.mjs b/front/start-prod.mjs index fe29bce..c4772a8 100644 --- a/front/start-prod.mjs +++ b/front/start-prod.mjs @@ -13,7 +13,7 @@ delete require.cache[require.resolve(configPath)]; const config = require(configPath); const port = config.production?.port || config.port || 6866; -const hostname = config.production?.hostname || '0.0.0.0'; +const hostname = process.env.FRONTEND_HOST || config.production?.hostname || '127.0.0.1'; const outDir = path.resolve(__dirname, 'out'); const contentTypes = new Map([ diff --git a/scripts/run_backend.ps1 b/scripts/run_backend.ps1 index 597272e..9818adb 100644 --- a/scripts/run_backend.ps1 +++ b/scripts/run_backend.ps1 @@ -72,6 +72,9 @@ foreach ($dir in @($DbDir, $DataDir, $OutputDir, $CacheDir, $LogDir, $ChromeProf if (-not $env:SPRING_DATASOURCE_URL) { $env:SPRING_DATASOURCE_URL = "jdbc:sqlite:$(Join-Path $DbDir 'getjobs.db')" } +if (-not $env:SERVER_ADDRESS) { + $env:SERVER_ADDRESS = "127.0.0.1" +} if (-not $env:APP_DATA_DIR) { $env:APP_DATA_DIR = $DataDir } diff --git a/src/main/java/com/getjobs/application/controller/ConfigController.java b/src/main/java/com/getjobs/application/controller/ConfigController.java index d390060..aebf5dc 100644 --- a/src/main/java/com/getjobs/application/controller/ConfigController.java +++ b/src/main/java/com/getjobs/application/controller/ConfigController.java @@ -1,8 +1,8 @@ package com.getjobs.application.controller; import com.getjobs.application.service.ConfigService; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -16,10 +16,9 @@ @Slf4j @RestController @RequestMapping("/api/config") +@RequiredArgsConstructor public class ConfigController { - - @Autowired - private ConfigService configService; + private final ConfigService configService; /** * 获取所有配置 @@ -30,10 +29,11 @@ public ResponseEntity> getAllConfigs() { Map response = new HashMap<>(); try { - Map configs = configService.getAllConfigsAsMap(); + Map configs = configService.getUiConfigsAsMap(); response.put("success", true); response.put("data", configs); + response.put("sensitive", configService.getSensitiveUiConfigStatus()); response.put("message", "获取配置成功"); return ResponseEntity.ok(response); @@ -56,11 +56,25 @@ public ResponseEntity> getConfigByKey(@PathVariable String k Map response = new HashMap<>(); try { + if (!configService.isUiConfigKeyAllowed(key)) { + throw new IllegalArgumentException("不允许读取该配置键: " + key); + } var config = configService.getConfigByKey(key); - if (config != null) { + if (config != null || configService.isSensitiveUiConfigKey(key)) { + Map data = new HashMap<>(); + data.put("config_key", key.toUpperCase()); + if (configService.isSensitiveUiConfigKey(key)) { + data.put("config_value", null); + data.put("sensitive", true); + data.put("configured", configService.isSensitiveUiConfigConfigured(key)); + } else { + data.put("config_value", config.getConfigValue()); + data.put("sensitive", false); + data.put("configured", config.getConfigValue() != null && !config.getConfigValue().isBlank()); + } response.put("success", true); - response.put("data", config); + response.put("data", data); response.put("message", "获取配置成功"); return ResponseEntity.ok(response); } else { @@ -69,6 +83,10 @@ public ResponseEntity> getConfigByKey(@PathVariable String k return ResponseEntity.notFound().build(); } + } catch (IllegalArgumentException e) { + response.put("success", false); + response.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(response); } catch (Exception e) { log.error("获取配置失败: {}", key, e); response.put("success", false); @@ -102,6 +120,10 @@ public ResponseEntity> batchUpdateConfigs(@RequestBody Map> updateConfig( return ResponseEntity.badRequest().body(response); } + } catch (IllegalArgumentException e) { + response.put("success", false); + response.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(response); } catch (Exception e) { log.error("更新配置失败: {}", key, e); response.put("success", false); @@ -152,6 +178,33 @@ public ResponseEntity> updateConfig( } } + /** + * 显式清除敏感配置。普通空字符串更新会保留原值,避免遮罩页面误覆盖。 + */ + @DeleteMapping("/{key}") + public ResponseEntity> clearSensitiveConfig(@PathVariable String key) { + Map response = new HashMap<>(); + try { + boolean success = configService.clearSensitiveUiConfig(key); + response.put("success", success); + boolean configured = success && configService.isSensitiveUiConfigConfigured(key); + response.put("configured", configured); + response.put("message", success + ? (configured ? "数据库值已清除,但同名环境变量仍在生效" : "敏感配置已清除") + : "敏感配置清除失败"); + return success ? ResponseEntity.ok(response) : ResponseEntity.internalServerError().body(response); + } catch (IllegalArgumentException e) { + response.put("success", false); + response.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(response); + } catch (Exception e) { + log.error("清除敏感配置失败: {}", key, e); + response.put("success", false); + response.put("message", "敏感配置清除失败: " + e.getMessage()); + return ResponseEntity.internalServerError().body(response); + } + } + /** * 健康检查接口 * @return 服务状态 diff --git a/src/main/java/com/getjobs/application/controller/CookieController.java b/src/main/java/com/getjobs/application/controller/CookieController.java index cbdd679..a692db5 100644 --- a/src/main/java/com/getjobs/application/controller/CookieController.java +++ b/src/main/java/com/getjobs/application/controller/CookieController.java @@ -1,6 +1,7 @@ package com.getjobs.application.controller; import com.getjobs.application.entity.CookieEntity; +import com.getjobs.application.controller.support.CookieResponseView; import com.getjobs.application.service.CookieService; import com.getjobs.worker.manager.PlaywrightManager; import lombok.RequiredArgsConstructor; @@ -38,19 +39,7 @@ public ResponseEntity> getCookie(@RequestParam("platform") S } CookieEntity cookie = cookieService.getCookieByPlatform(platform); - Map data = new HashMap<>(); - if (cookie != null) { - data.put("id", cookie.getId()); - data.put("platform", cookie.getPlatform()); - data.put("cookie_value", cookie.getCookieValue()); - data.put("remark", cookie.getRemark()); - data.put("created_at", cookie.getCreatedAt()); - data.put("updated_at", cookie.getUpdatedAt()); - } else { - data.put("platform", platform); - data.put("cookie_value", null); - data.put("message", "未找到Cookie记录"); - } + Map data = CookieResponseView.from(cookie, platform, "未找到Cookie记录"); response.put("success", true); response.put("data", data); return ResponseEntity.ok(response); diff --git a/src/main/java/com/getjobs/application/controller/JobController.java b/src/main/java/com/getjobs/application/controller/JobController.java index 2f874ee..1180682 100644 --- a/src/main/java/com/getjobs/application/controller/JobController.java +++ b/src/main/java/com/getjobs/application/controller/JobController.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.getjobs.application.entity.CookieEntity; +import com.getjobs.application.controller.support.CookieResponseView; import com.getjobs.application.entity.Job51ConfigEntity; import com.getjobs.application.entity.Job51OptionEntity; import com.getjobs.application.service.CookieService; @@ -346,19 +347,7 @@ public ResponseEntity> get51jobCookieRecord() { Map response = new HashMap<>(); try { CookieEntity cookie = cookieService.getCookieByPlatform("51job"); - Map data = new HashMap<>(); - if (cookie != null) { - data.put("id", cookie.getId()); - data.put("platform", cookie.getPlatform()); - data.put("cookie_value", cookie.getCookieValue()); - data.put("remark", cookie.getRemark()); - data.put("created_at", cookie.getCreatedAt()); - data.put("updated_at", cookie.getUpdatedAt()); - } else { - data.put("platform", "51job"); - data.put("cookie_value", null); - data.put("message", "未找到51job Cookie记录"); - } + Map data = CookieResponseView.from(cookie, "51job", "未找到51job Cookie记录"); response.put("success", true); response.put("data", data); return ResponseEntity.ok(response); diff --git a/src/main/java/com/getjobs/application/controller/LiepinController.java b/src/main/java/com/getjobs/application/controller/LiepinController.java index c605ca2..40eeb6e 100644 --- a/src/main/java/com/getjobs/application/controller/LiepinController.java +++ b/src/main/java/com/getjobs/application/controller/LiepinController.java @@ -1,6 +1,7 @@ package com.getjobs.application.controller; import com.getjobs.application.entity.CookieEntity; +import com.getjobs.application.controller.support.CookieResponseView; import com.getjobs.application.entity.LiepinConfigEntity; import com.getjobs.application.entity.LiepinOptionEntity; import com.getjobs.application.service.CookieService; @@ -279,19 +280,7 @@ public ResponseEntity> getLiepinCookieRecord() { Map response = new HashMap<>(); try { CookieEntity cookie = cookieService.getCookieByPlatform("liepin"); - Map data = new HashMap<>(); - if (cookie != null) { - data.put("id", cookie.getId()); - data.put("platform", cookie.getPlatform()); - data.put("cookie_value", cookie.getCookieValue()); - data.put("remark", cookie.getRemark()); - data.put("created_at", cookie.getCreatedAt()); - data.put("updated_at", cookie.getUpdatedAt()); - } else { - data.put("platform", "liepin"); - data.put("cookie_value", null); - data.put("message", "未找到猎聘Cookie记录"); - } + Map data = CookieResponseView.from(cookie, "liepin", "未找到猎聘Cookie记录"); response.put("success", true); response.put("data", data); return ResponseEntity.ok(response); diff --git a/src/main/java/com/getjobs/application/controller/ZhilianController.java b/src/main/java/com/getjobs/application/controller/ZhilianController.java index 5d314cc..f9673fb 100644 --- a/src/main/java/com/getjobs/application/controller/ZhilianController.java +++ b/src/main/java/com/getjobs/application/controller/ZhilianController.java @@ -6,6 +6,7 @@ import com.getjobs.application.dto.ConfirmBatchRequest; import com.getjobs.application.dto.DeliveryResultRequest; import com.getjobs.application.entity.CookieEntity; +import com.getjobs.application.controller.support.CookieResponseView; import com.getjobs.application.entity.ZhilianConfigEntity; import com.getjobs.application.entity.ZhilianJobDataEntity; import com.getjobs.application.service.ChromeJobAnalysisQueueService; @@ -251,19 +252,7 @@ public ResponseEntity> getZhilianCookieRecord() { Map response = new HashMap<>(); try { CookieEntity cookie = cookieService.getCookieByPlatform("zhilian"); - Map data = new HashMap<>(); - if (cookie != null) { - data.put("id", cookie.getId()); - data.put("platform", cookie.getPlatform()); - data.put("cookie_value", cookie.getCookieValue()); - data.put("remark", cookie.getRemark()); - data.put("created_at", cookie.getCreatedAt()); - data.put("updated_at", cookie.getUpdatedAt()); - } else { - data.put("platform", "zhilian"); - data.put("cookie_value", null); - data.put("message", "未找到智联招聘Cookie记录"); - } + Map data = CookieResponseView.from(cookie, "zhilian", "未找到智联招聘Cookie记录"); response.put("success", true); response.put("data", data); return ResponseEntity.ok(response); diff --git a/src/main/java/com/getjobs/application/controller/support/CookieResponseView.java b/src/main/java/com/getjobs/application/controller/support/CookieResponseView.java new file mode 100644 index 0000000..f7a071f --- /dev/null +++ b/src/main/java/com/getjobs/application/controller/support/CookieResponseView.java @@ -0,0 +1,32 @@ +package com.getjobs.application.controller.support; + +import com.getjobs.application.entity.CookieEntity; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 构造 Cookie 查询接口的安全响应,只暴露状态和非敏感元数据。 + */ +public final class CookieResponseView { + private CookieResponseView() { + } + + public static Map from(CookieEntity cookie, String platform, String missingMessage) { + Map data = new LinkedHashMap<>(); + if (cookie == null) { + data.put("platform", platform); + data.put("configured", false); + data.put("message", missingMessage); + return data; + } + + data.put("id", cookie.getId()); + data.put("platform", cookie.getPlatform()); + data.put("configured", cookie.getCookieValue() != null && !cookie.getCookieValue().isBlank()); + data.put("remark", cookie.getRemark()); + data.put("created_at", cookie.getCreatedAt()); + data.put("updated_at", cookie.getUpdatedAt()); + return data; + } +} diff --git a/src/main/java/com/getjobs/application/service/CodexCliService.java b/src/main/java/com/getjobs/application/service/CodexCliService.java index e28c0bd..5d4f54d 100644 --- a/src/main/java/com/getjobs/application/service/CodexCliService.java +++ b/src/main/java/com/getjobs/application/service/CodexCliService.java @@ -132,6 +132,7 @@ List buildCommand(String executable, String model, Path workingDirectory } private String resolveExecutable(String configured) { + validateExecutableName(configured); Path configuredPath = Path.of(configured); if (configuredPath.isAbsolute() || configuredPath.getParent() != null) { if (Files.isRegularFile(configuredPath)) { @@ -151,7 +152,7 @@ private String resolveExecutable(String configured) { for (String directory : directories) { if (directory == null || directory.isBlank()) continue; String normalizedDirectory = directory.trim().replaceAll("^\"|\"$", ""); - for (String extension : List.of(".exe", ".com", ".cmd", ".bat", ".ps1", "")) { + for (String extension : List.of(".exe", ".cmd", ".bat", ".ps1", "")) { Path candidate = Path.of(normalizedDirectory).resolve(configured + extension); if (Files.isRegularFile(candidate)) { return candidate.toAbsolutePath().normalize().toString(); @@ -162,6 +163,22 @@ private String resolveExecutable(String configured) { throw new IllegalStateException("未找到 Codex CLI:" + configured); } + void validateExecutableName(String configured) { + if (configured == null || configured.isBlank()) { + throw new IllegalArgumentException("CODEX_PATH 不能为空"); + } + final String fileName; + try { + Path file = Path.of(configured.trim()).getFileName(); + fileName = file == null ? "" : file.toString().toLowerCase(Locale.ROOT); + } catch (RuntimeException e) { + throw new IllegalArgumentException("CODEX_PATH 不是有效路径", e); + } + if (!List.of("codex", "codex.exe", "codex.cmd", "codex.bat", "codex.ps1").contains(fileName)) { + throw new IllegalArgumentException("CODEX_PATH 仅允许 Codex CLI 启动文件,不允许其他程序或命令参数"); + } + } + private void addExecutable(List command, String executable) { String lower = executable.toLowerCase(Locale.ROOT); if (lower.endsWith(".cmd") || lower.endsWith(".bat")) { diff --git a/src/main/java/com/getjobs/application/service/ConfigService.java b/src/main/java/com/getjobs/application/service/ConfigService.java index 04b9fe9..be9f876 100644 --- a/src/main/java/com/getjobs/application/service/ConfigService.java +++ b/src/main/java/com/getjobs/application/service/ConfigService.java @@ -20,8 +20,10 @@ import java.time.LocalDateTime; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * 配置服务类 @@ -30,12 +32,26 @@ @Service @RequiredArgsConstructor public class ConfigService { + private static final Set UI_CONFIG_KEYS = Set.of( + "AI_PROVIDER", + "BASE_URL", + "API_KEY", + "MODEL", + "CODEX_PATH", + "CODEX_MODEL", + "CODEX_TIMEOUT_SECONDS", + "HOOK_URL", + "BOT_IS_SEND" + ); + private static final Set SENSITIVE_UI_CONFIG_KEYS = Set.of("API_KEY", "HOOK_URL"); + private final ConfigMapper configMapper; private final LiepinService liepinService; private final BossService bossService; private final ZhilianService zhilianService; private final Job51Service job51Service; private final Environment environment; + private final CodexCliService codexCliService; /** * 获取所有配置(以Map形式返回) @@ -52,6 +68,55 @@ public Map getAllConfigsAsMap() { return configMap; } + /** + * 获取可安全返回给环境配置页面的配置。敏感值只保留键,不返回原文。 + */ + public Map getUiConfigsAsMap() { + List configs = configMapper.selectList(null); + Map configMap = new LinkedHashMap<>(); + for (String sensitiveKey : SENSITIVE_UI_CONFIG_KEYS) { + configMap.put(sensitiveKey, null); + } + for (ConfigEntity config : configs) { + String key = normalizeUiConfigKey(config.getConfigKey()); + if (UI_CONFIG_KEYS.contains(key) && !SENSITIVE_UI_CONFIG_KEYS.contains(key)) { + configMap.put(key, config.getConfigValue()); + } + } + return configMap; + } + + /** + * 仅返回敏感配置是否存在,不返回配置原值。 + */ + public Map getSensitiveUiConfigStatus() { + Map status = new LinkedHashMap<>(); + for (String key : SENSITIVE_UI_CONFIG_KEYS) { + status.put(key, isSensitiveUiConfigConfigured(key)); + } + return status; + } + + public boolean isSensitiveUiConfigConfigured(String configKey) { + String key = normalizeUiConfigKey(configKey); + if (!SENSITIVE_UI_CONFIG_KEYS.contains(key)) { + throw new IllegalArgumentException("不是可管理的敏感配置键: " + key); + } + String value = getConfigValue(key); + if (value == null || value.isBlank()) { + value = environment.getProperty(key); + } + return value != null && !value.isBlank(); + } + + public boolean isUiConfigKeyAllowed(String configKey) { + return UI_CONFIG_KEYS.contains(normalizeUiConfigKey(configKey)); + } + + public boolean isSensitiveUiConfigKey(String configKey) { + return SENSITIVE_UI_CONFIG_KEYS.contains(normalizeUiConfigKey(configKey)); + } + /** * 获取所有配置 * @return 配置列表 @@ -139,12 +204,25 @@ public Map getAiConfigs() { */ @Transactional public int batchUpdateConfigs(Map configMap) { + if (configMap == null) { + throw new IllegalArgumentException("配置数据不能为空"); + } + for (Map.Entry entry : configMap.entrySet()) { + String key = validateUiConfigKey(entry.getKey()); + validateUiConfigValue(key, entry.getValue()); + } + int updateCount = 0; for (Map.Entry entry : configMap.entrySet()) { - String key = entry.getKey(); + String key = normalizeUiConfigKey(entry.getKey()); String value = entry.getValue(); + // 敏感输入为空代表页面没有提供新值,保留数据库中的现有值。 + if (SENSITIVE_UI_CONFIG_KEYS.contains(key) && (value == null || value.isBlank())) { + continue; + } + ConfigEntity config = getConfigByKey(key); if (config != null) { @@ -177,7 +255,12 @@ public int batchUpdateConfigs(Map configMap) { */ @Transactional public boolean updateConfig(String configKey, String configValue) { - ConfigEntity config = getConfigByKey(configKey); + String key = validateUiConfigKey(configKey); + validateUiConfigValue(key, configValue); + if (SENSITIVE_UI_CONFIG_KEYS.contains(key) && (configValue == null || configValue.isBlank())) { + return true; + } + ConfigEntity config = getConfigByKey(key); if (config != null) { config.setConfigValue(configValue); @@ -185,22 +268,44 @@ public boolean updateConfig(String configKey, String configValue) { int result = configMapper.updateById(config); if (result > 0) { - log.info("更新配置成功: {} = {}", configKey, displayConfigValue(configKey, configValue)); + log.info("更新配置成功: {} = {}", key, displayConfigValue(key, configValue)); return true; } } else { ConfigEntity created = new ConfigEntity(); - created.setConfigKey(configKey); + created.setConfigKey(key); created.setConfigValue(configValue); created.setConfigType("string"); - created.setCategory(resolveConfigCategory(configKey)); - created.setDescription(resolveConfigDescription(configKey)); + created.setCategory(resolveConfigCategory(key)); + created.setDescription(resolveConfigDescription(key)); return createConfig(created); } return false; } + /** + * 显式清除可由 UI 管理的敏感配置。不存在时视为已经清除。 + */ + @Transactional + public boolean clearSensitiveUiConfig(String configKey) { + String key = normalizeUiConfigKey(configKey); + if (!SENSITIVE_UI_CONFIG_KEYS.contains(key)) { + throw new IllegalArgumentException("仅允许清除 API_KEY 或 HOOK_URL"); + } + ConfigEntity config = getConfigByKey(key); + if (config == null) { + return true; + } + config.setConfigValue(""); + config.setUpdatedAt(LocalDateTime.now()); + int result = configMapper.updateById(config); + if (result > 0) { + log.info("已清除敏感配置: {}", key); + } + return result > 0; + } + /** * 创建新配置 * @param config 配置实体 @@ -240,6 +345,24 @@ private String optionalAiConfigValue(String configKey, String defaultValue) { return value == null || value.isBlank() ? defaultValue : value.trim(); } + private String validateUiConfigKey(String configKey) { + String key = normalizeUiConfigKey(configKey); + if (!UI_CONFIG_KEYS.contains(key)) { + throw new IllegalArgumentException("不允许通过配置接口读写该配置键: " + key); + } + return key; + } + + private void validateUiConfigValue(String configKey, String configValue) { + if ("CODEX_PATH".equals(configKey)) { + codexCliService.validateExecutableName(configValue); + } + } + + private String normalizeUiConfigKey(String configKey) { + return configKey == null ? "" : configKey.trim().toUpperCase(); + } + private String resolveConfigCategory(String configKey) { if (configKey == null) { return "general"; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3c2f3a9..03cd356 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,6 +37,7 @@ spring: # 服务器配置 server: + address: ${SERVER_ADDRESS:127.0.0.1} port: ${SERVER_PORT:8888} # 后端 API 固定端口 app: diff --git a/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java b/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java index 5032133..a87e1a8 100644 --- a/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java +++ b/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java @@ -19,6 +19,7 @@ void readsApplicationYamlConfiguration() throws Exception { JsonNode root = yamlMapper.readTree(Path.of("src/main/resources/application.yaml").toFile()); assertThat(root.path("server").path("port").asText()).contains("8888"); + assertThat(root.path("server").path("address").asText()).contains("127.0.0.1"); assertThat(root.path("spring").path("datasource").path("url").asText()).contains("jdbc:sqlite"); assertThat(root.path("app").path("paths").path("data-dir").asText()).contains("APP_DATA_DIR"); } @@ -41,5 +42,14 @@ void productionFrontendScriptsUseNextOutDirectory() throws Exception { assertThat(copyScript).contains("..', 'out'"); assertThat(startScript).contains("'out'"); assertThat(startScript).contains("http.createServer"); + assertThat(startScript).contains("127.0.0.1"); + } + + @Test + void dockerOverridesContainerBindAddressButKeepsHostLoopbackOnly() throws Exception { + String compose = Files.readString(Path.of("docker-compose.yml"), StandardCharsets.UTF_8); + + assertThat(compose).contains("SERVER_ADDRESS: 0.0.0.0"); + assertThat(compose).contains("127.0.0.1:${BACKEND_PORT:-8888}:8888"); } } diff --git a/src/test/java/com/getjobs/application/controller/ConfigControllerSecurityTest.java b/src/test/java/com/getjobs/application/controller/ConfigControllerSecurityTest.java new file mode 100644 index 0000000..7ae2101 --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/ConfigControllerSecurityTest.java @@ -0,0 +1,52 @@ +package com.getjobs.application.controller; + +import com.getjobs.application.entity.ConfigEntity; +import com.getjobs.application.service.ConfigService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConfigControllerSecurityTest { + @Mock + private ConfigService configService; + + @Test + void singleSensitiveConfigReturnsStatusWithoutRawValue() { + ConfigEntity secret = new ConfigEntity(); + secret.setConfigKey("API_KEY"); + secret.setConfigValue("sk-real-secret"); + when(configService.isUiConfigKeyAllowed("API_KEY")).thenReturn(true); + when(configService.getConfigByKey("API_KEY")).thenReturn(secret); + when(configService.isSensitiveUiConfigKey("API_KEY")).thenReturn(true); + when(configService.isSensitiveUiConfigConfigured("API_KEY")).thenReturn(true); + + var response = new ConfigController(configService).getConfigByKey("API_KEY"); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isNotNull(); + @SuppressWarnings("unchecked") + Map data = (Map) response.getBody().get("data"); + assertThat(data) + .containsEntry("configured", true) + .containsEntry("sensitive", true) + .containsEntry("config_value", null); + assertThat(response.getBody().toString()).doesNotContain("sk-real-secret"); + } + + @Test + void rejectsUnknownSingleConfigRead() { + when(configService.isUiConfigKeyAllowed("CODEX_HOME")).thenReturn(false); + + var response = new ConfigController(configService).getConfigByKey("CODEX_HOME"); + + assertThat(response.getStatusCode().is4xxClientError()).isTrue(); + assertThat(response.getBody()).containsEntry("success", false); + } +} diff --git a/src/test/java/com/getjobs/application/controller/CookieControllerSecurityTest.java b/src/test/java/com/getjobs/application/controller/CookieControllerSecurityTest.java new file mode 100644 index 0000000..5988d2d --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/CookieControllerSecurityTest.java @@ -0,0 +1,35 @@ +package com.getjobs.application.controller; + +import com.getjobs.application.entity.CookieEntity; +import com.getjobs.application.service.CookieService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CookieControllerSecurityTest { + @Mock + private CookieService cookieService; + + @Test + void cookieEndpointReturnsConfiguredStateWithoutRawCookie() { + CookieEntity cookie = new CookieEntity(); + cookie.setId(7L); + cookie.setPlatform("boss"); + cookie.setCookieValue("session=real-secret-cookie"); + when(cookieService.getCookieByPlatform("boss")).thenReturn(cookie); + + var response = new CookieController(cookieService, null).getCookie("boss"); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().toString()) + .contains("configured=true") + .doesNotContain("cookie_value") + .doesNotContain("real-secret-cookie"); + } +} diff --git a/src/test/java/com/getjobs/application/controller/support/CookieResponseViewTest.java b/src/test/java/com/getjobs/application/controller/support/CookieResponseViewTest.java new file mode 100644 index 0000000..4d380e0 --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/support/CookieResponseViewTest.java @@ -0,0 +1,36 @@ +package com.getjobs.application.controller.support; + +import com.getjobs.application.entity.CookieEntity; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CookieResponseViewTest { + @Test + void configuredCookieResponseNeverContainsRawCookie() { + CookieEntity cookie = new CookieEntity(); + cookie.setId(42L); + cookie.setPlatform("boss"); + cookie.setCookieValue("session=real-secret-cookie"); + cookie.setRemark("manual save"); + + var data = CookieResponseView.from(cookie, "boss", "未找到Cookie记录"); + + assertThat(data) + .containsEntry("id", 42L) + .containsEntry("platform", "boss") + .containsEntry("configured", true) + .doesNotContainKey("cookie_value"); + assertThat(data.toString()).doesNotContain("real-secret-cookie"); + } + + @Test + void missingCookieReturnsExplicitRecoverableState() { + var data = CookieResponseView.from(null, "liepin", "未找到猎聘Cookie记录"); + + assertThat(data) + .containsEntry("platform", "liepin") + .containsEntry("configured", false) + .containsEntry("message", "未找到猎聘Cookie记录"); + } +} diff --git a/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java b/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java index 63d99ed..815c6e4 100644 --- a/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java +++ b/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java @@ -1,11 +1,14 @@ package com.getjobs.application.service; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import java.nio.file.Path; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class CodexCliServiceTest { @Test @@ -45,4 +48,40 @@ void commandWrapsWindowsCmdLauncher() { "C:\\Users\\demo\\AppData\\Roaming\\npm\\codex.cmd" ); } + + @Test + void executableValidationAllowsPortableCodexName() { + CodexCliService service = new CodexCliService(); + + service.validateExecutableName("codex"); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void executableValidationAllowsWindowsCodexLaunchersOnWindows() { + CodexCliService service = new CodexCliService(); + + service.validateExecutableName("C:\\Users\\demo\\AppData\\Roaming\\npm\\codex.cmd"); + service.validateExecutableName("C:\\tools\\codex.exe"); + } + + @Test + @EnabledOnOs({OS.LINUX, OS.MAC}) + void executableValidationAllowsUnixCodexLauncherOnUnix() { + CodexCliService service = new CodexCliService(); + + service.validateExecutableName("/usr/local/bin/codex"); + } + + @Test + void executableValidationRejectsOtherProgramsAndInjectedArguments() { + CodexCliService service = new CodexCliService(); + + assertThatThrownBy(() -> service.validateExecutableName("powershell.exe")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("仅允许 Codex CLI"); + assertThatThrownBy(() -> service.validateExecutableName("codex.cmd --dangerous-argument")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("不允许其他程序或命令参数"); + } } diff --git a/src/test/java/com/getjobs/application/service/ConfigServiceTest.java b/src/test/java/com/getjobs/application/service/ConfigServiceTest.java index 676bd7b..d19e48a 100644 --- a/src/test/java/com/getjobs/application/service/ConfigServiceTest.java +++ b/src/test/java/com/getjobs/application/service/ConfigServiceTest.java @@ -13,8 +13,10 @@ import org.springframework.core.env.Environment; import java.util.Map; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -37,7 +39,8 @@ void setUp() { null, null, null, - environment + environment, + new CodexCliService() ); } @@ -127,4 +130,76 @@ void apiKeyValueIsHiddenInLogs(CapturedOutput output) { assertThat(output).doesNotContain("sk-real-secret"); } + @Test + void uiConfigSnapshotDoesNotExposeSecretsOrCodexHome() { + ConfigEntity apiKey = config("API_KEY", "sk-real-secret"); + ConfigEntity hookUrl = config("HOOK_URL", "https://example.test/secret-hook"); + ConfigEntity codexHome = config("CODEX_HOME", "C:/private/codex-home"); + ConfigEntity model = config("MODEL", "deepseek-chat"); + when(configMapper.selectList(null)).thenReturn(List.of(apiKey, hookUrl, codexHome, model)); + + Map configs = configService.getUiConfigsAsMap(); + + assertThat(configs) + .containsEntry("MODEL", "deepseek-chat") + .containsEntry("API_KEY", null) + .containsEntry("HOOK_URL", null) + .doesNotContainKey("CODEX_HOME"); + assertThat(configs.toString()) + .doesNotContain("sk-real-secret") + .doesNotContain("secret-hook") + .doesNotContain("private/codex-home"); + } + + @Test + void blankSensitiveValuePreservesExistingConfig() { + ConfigEntity model = config("MODEL", "old-model"); + when(configMapper.selectOne(any())).thenReturn(model); + when(configMapper.updateById(any(ConfigEntity.class))).thenReturn(1); + + int count = configService.batchUpdateConfigs(Map.of( + "API_KEY", "", + "MODEL", "new-model" + )); + + assertThat(count).isEqualTo(1); + verify(configMapper).updateById(model); + assertThat(model.getConfigValue()).isEqualTo("new-model"); + } + + @Test + void rejectsUnknownUiConfigBeforeWriting() { + assertThatThrownBy(() -> configService.batchUpdateConfigs(Map.of("CODEX_HOME", "C:/private"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("不允许"); + + verify(configMapper, never()).insert(any(ConfigEntity.class)); + verify(configMapper, never()).updateById(any(ConfigEntity.class)); + } + + @Test + void rejectsNonCodexExecutableBeforeWriting() { + assertThatThrownBy(() -> configService.batchUpdateConfigs(Map.of("CODEX_PATH", "powershell.exe"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("仅允许 Codex CLI"); + + verify(configMapper, never()).insert(any(ConfigEntity.class)); + verify(configMapper, never()).updateById(any(ConfigEntity.class)); + } + + @Test + void sensitiveConfiguredStatusCanUseEnvironmentWithoutReturningValue() { + when(configMapper.selectOne(any())).thenReturn(null); + when(environment.getProperty("API_KEY")).thenReturn("env-secret"); + + assertThat(configService.isSensitiveUiConfigConfigured("API_KEY")).isTrue(); + } + + private ConfigEntity config(String key, String value) { + ConfigEntity entity = new ConfigEntity(); + entity.setConfigKey(key); + entity.setConfigValue(value); + return entity; + } + } diff --git a/start_windows.ps1 b/start_windows.ps1 index ccc76ba..e0b4e13 100644 --- a/start_windows.ps1 +++ b/start_windows.ps1 @@ -241,6 +241,9 @@ Write-Host "运行目录已准备完成。" if (-not $env:SPRING_DATASOURCE_URL) { $env:SPRING_DATASOURCE_URL = "jdbc:sqlite:$(Join-Path $DbDir 'getjobs.db')" } +if (-not $env:SERVER_ADDRESS) { + $env:SERVER_ADDRESS = "127.0.0.1" +} if (-not $env:LOGGING_FILE_NAME) { $env:LOGGING_FILE_NAME = Join-Path $TargetLogDir "get-jobs.log" } diff --git a/tasks/2026-08-24-p0-1-local-security-boundary.md b/tasks/2026-08-24-p0-1-local-security-boundary.md new file mode 100644 index 0000000..fad94b3 --- /dev/null +++ b/tasks/2026-08-24-p0-1-local-security-boundary.md @@ -0,0 +1,67 @@ +# P0.1 本地安全边界与敏感配置收口 + +## 背景 + +工程审计确认:原生启动时后端与前端生产服务可能监听全部网卡;配置接口和 Cookie 查询接口会把敏感值原样返回;`CODEX_PATH` 可被配置为任意本地可执行文件。这些问题在个人本机开发时不一定立即出错,但会扩大误暴露和误执行的风险。 + +## 目标 + +1. 原生 Windows 启动默认只监听 `127.0.0.1`。 +2. Docker 内部服务保留容器所需的 `0.0.0.0`,宿主端口继续只发布到回环地址。 +3. HTTP 配置接口只读写明确允许的 UI 配置键;`API_KEY`、`HOOK_URL` 只返回“是否已配置”,不回传原值。 +4. Cookie 查询接口只返回“是否已配置”和非敏感元数据,不回传 Cookie 原文。 +5. `CODEX_PATH` 只允许 Codex CLI 的受支持启动文件名,不允许借配置执行任意程序或附加命令参数。 + +## 允许修改范围 + +- 服务监听配置、Docker Compose 覆盖项和本地启动脚本。 +- 配置与 Cookie HTTP 展示层、环境配置页面。 +- Codex CLI 路径校验。 +- 与本轮行为直接相关的单元测试、配置烟雾测试和接口文档。 + +## 禁止修改范围 + +- 不修改数据库 Schema、Migration 或现有数据。 +- 不改变 AI Provider 选择、请求协议、模型路由或业务流程。 +- 不改变 Codex 登录方式、认证目录或现有内部读取逻辑。 +- 不调用真实 AI、招聘平台、Webhook 或其他外部服务。 +- 不删除历史配置、Cookie、代码或依赖。 + +## 已确定实现要求 + +- `server.address` 原生默认值为 `127.0.0.1`,允许通过 `SERVER_ADDRESS` 显式覆盖。 +- Docker Compose 后端显式设置 `SERVER_ADDRESS=0.0.0.0`;宿主端口保持 `127.0.0.1:8888:8888`。 +- 前端生产服务默认监听 `127.0.0.1`,允许环境变量显式覆盖;容器现有显式 `-H 0.0.0.0` 行为不变。 +- UI 可管理配置键采用白名单;`CODEX_HOME` 不通过 HTTP 暴露或修改,但内部历史值继续按原逻辑读取。 +- 敏感配置普通保存请求中的空字符串视为“保持原值”,防止遮罩后被旧页面误清空;显式清除使用受限的删除接口。 +- Cookie 内部注入流程继续读取原值,只有 HTTP 响应做脱敏。 +- `CODEX_PATH` 接受 `codex`、`codex.exe`、`codex.cmd`、`codex.bat`、`codex.ps1` 及其完整路径,拒绝其他文件名和带参数的字符串。 + +## 验收标准 + +- 未设置覆盖变量时,本地前后端仅监听回环地址。 +- Docker Compose 仍能从前端容器访问后端容器,且宿主发布地址仍是回环地址。 +- 所有配置 GET 响应均不包含 `API_KEY`、`HOOK_URL` 的原值,也不包含 `CODEX_HOME`。 +- 所有 Cookie GET 响应均不包含 Cookie 原文。 +- 未修改敏感输入时保存其他设置,不会覆盖已保存的敏感值。 +- 非白名单配置键和非 Codex 可执行文件被明确拒绝。 +- 内部 AI、Bot、浏览器注入仍能按原逻辑读取敏感值。 +- 后端测试、前端 lint/typecheck/build 与扩展测试通过;测试过程不触发真实外部调用。 + +## 测试命令 + +```powershell +gradlew.bat test +pnpm --dir front lint +pnpm --dir front typecheck +pnpm --dir front build +$extensionTests = Get-ChildItem chrome-extension/tests/*.test.cjs | ForEach-Object { $_.FullName } +node --test $extensionTests +``` + +## 返回格式 + +- 修改文件与关键行为摘要。 +- 测试命令、退出码和失败证据(如有)。 +- 范围外变更检查、敏感信息检查和 Git diff 摘要。 +- Commit、分支、Push 与 PR 状态。