This is an automated email from the ASF dual-hosted git repository. lizhimins pushed a commit to branch rocketmq-studio in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
commit afe2ac79e452b8df845ea03164388adb00a26c77 Author: yx9o <[email protected]> AuthorDate: Fri Jul 24 13:43:28 2026 +0800 fix: avoid returning General Settings API key (#512) Fix security issue where General Settings endpoint returned API key in plaintext. Replace with apiKeyConfigured boolean flag and support explicit key clearing. --- docs/api-spec.md | 18 +++++- .../studio/settings/GeneralSettingsUpdateDTO.java | 73 ++++++++++++++++++++++ .../studio/settings/GeneralSettingsVO.java | 12 ++++ .../studio/settings/SettingsController.java | 5 +- .../rocketmq/studio/settings/SettingsService.java | 10 ++- .../studio/settings/SettingsControllerTest.java | 71 ++++++++++++++++++--- .../studio/settings/SettingsServiceTest.java | 61 ++++++++++++++++-- web/src/api/generalSettings.test.ts | 47 ++++++++++++-- web/src/api/settings.ts | 14 ++++- web/src/pages/settings/index.tsx | 38 +++++++++-- 10 files changed, 318 insertions(+), 31 deletions(-) diff --git a/docs/api-spec.md b/docs/api-spec.md index 2b0bd143..e75158f9 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -1483,7 +1483,7 @@ GET /api/settings/general | `sessionTimeout` | `number` | 会话超时(分钟,5-1440) | | `requireLogin` | `boolean` | 是否需要登录 | | `llmProvider` | `string` | LLM 提供商: `openai` / `azure` / `ollama` / `qwen` | -| `apiKey` | `string` | API Key | +| `apiKeyConfigured` | `boolean` | 是否已配置 API Key;响应不会返回密钥内容 | | `model` | `string` | 模型名称 | | `baseUrl` | `string` | Base URL | @@ -1493,7 +1493,21 @@ GET /api/settings/general POST /api/settings/general/save ``` -**Request Body:** 同 14.1 响应格式 +**Request Body:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `theme` | `string` | 是 | 主题: `light` / `dark` / `system` | +| `compact` | `boolean` | 是 | 紧凑模式 | +| `desktopNotify` | `boolean` | 是 | 桌面通知 | +| `notifySound` | `boolean` | 是 | 通知声音 | +| `sessionTimeout` | `number` | 是 | 会话超时(分钟,5-1440) | +| `requireLogin` | `boolean` | 是 | 是否需要登录 | +| `llmProvider` | `string` | 是 | LLM 提供商 | +| `apiKey` | `string` | 否 | 新 API Key;省略或传空值时保留现有密钥 | +| `clearApiKey` | `boolean` | 否 | 传 `true` 时显式清除现有密钥,优先级高于 `apiKey` | +| `model` | `string` | 是 | 模型名称 | +| `baseUrl` | `string` | 是 | Base URL | **Response `data`:** `null` diff --git a/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java new file mode 100644 index 00000000..fbfea401 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.settings; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GeneralSettingsUpdateDTO { + @NotBlank + private String theme; + @NotNull + private Boolean compact; + @NotNull + private Boolean desktopNotify; + @NotNull + private Boolean notifySound; + @NotNull + @Min(5) + @Max(1440) + private Integer sessionTimeout; + @NotNull + private Boolean requireLogin; + @NotBlank + private String llmProvider; + @ToString.Exclude + private String apiKey; + private boolean clearApiKey; + @NotBlank + private String model; + @NotNull + private String baseUrl; + + public GeneralSettingsVO toSettings() { + return GeneralSettingsVO.builder() + .theme(theme) + .compact(compact) + .desktopNotify(desktopNotify) + .notifySound(notifySound) + .sessionTimeout(sessionTimeout) + .requireLogin(requireLogin) + .llmProvider(llmProvider) + .apiKey(apiKey) + .clearApiKey(clearApiKey) + .model(model) + .baseUrl(baseUrl) + .build(); + } +} diff --git a/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java index 8dd3bd6e..bfb60833 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java +++ b/server/src/main/java/com/rocketmq/studio/settings/GeneralSettingsVO.java @@ -16,10 +16,13 @@ */ package com.rocketmq.studio.settings; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.util.StringUtils; @Data @Builder @@ -33,7 +36,16 @@ public class GeneralSettingsVO { private int sessionTimeout; private boolean requireLogin; private String llmProvider; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @ToString.Exclude private String apiKey; + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private boolean clearApiKey; private String model; private String baseUrl; + + @JsonProperty(value = "apiKeyConfigured", access = JsonProperty.Access.READ_ONLY) + public boolean isApiKeyConfigured() { + return StringUtils.hasText(apiKey); + } } diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java index a9a91234..a84923de 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java @@ -17,6 +17,7 @@ package com.rocketmq.studio.settings; import com.rocketmq.studio.common.domain.Result; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -40,8 +41,8 @@ public class SettingsController { } @PostMapping("/general/save") - public Result<Void> saveGeneralSettings(@RequestBody GeneralSettingsVO settings) { - settingsService.saveGeneralSettings(settings); + public Result<Void> saveGeneralSettings(@Valid @RequestBody GeneralSettingsUpdateDTO request) { + settingsService.saveGeneralSettings(request.toSettings()); return Result.ok(); } diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java index 5505d27f..2c2db875 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java @@ -19,6 +19,7 @@ package com.rocketmq.studio.settings; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.util.List; import java.util.UUID; @@ -37,8 +38,15 @@ public class SettingsService { } - public void saveGeneralSettings(GeneralSettingsVO settings) { + public synchronized void saveGeneralSettings(GeneralSettingsVO settings) { log.info("Saving general settings"); + GeneralSettingsVO currentSettings = settingsRepository.loadGeneralSettings(); + if (settings.isClearApiKey()) { + settings.setApiKey(""); + } else if (!StringUtils.hasText(settings.getApiKey()) && currentSettings != null) { + settings.setApiKey(currentSettings.getApiKey()); + } + settings.setClearApiKey(false); settingsRepository.saveGeneralSettings(settings); } diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java index 42a3c0e6..f82bb1ad 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java @@ -30,8 +30,10 @@ import java.util.Collections; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -77,26 +79,81 @@ class SettingsControllerTest { .andExpect(jsonPath("$.data.sessionTimeout", is(30))) .andExpect(jsonPath("$.data.requireLogin", is(true))) .andExpect(jsonPath("$.data.llmProvider", is("openai"))) + .andExpect(jsonPath("$.data.apiKey").doesNotExist()) + .andExpect(jsonPath("$.data.apiKeyConfigured", is(true))) + .andExpect(jsonPath("$.data.clearApiKey").doesNotExist()) .andExpect(jsonPath("$.data.model", is("gpt-4"))); } @Test void saveGeneralSettingsShouldReturnSuccess() throws Exception { - GeneralSettingsVO settings = GeneralSettingsVO.builder() - .theme("light") - .compact(false) - .sessionTimeout(60) - .build(); doNothing().when(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class)); mockMvc.perform(post("/api/settings/general/save") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(settings))) + .content(""" + { + "theme": "light", + "compact": false, + "desktopNotify": true, + "notifySound": false, + "sessionTimeout": 60, + "requireLogin": true, + "llmProvider": "openai", + "apiKey": "sk-new", + "apiKeyConfigured": false, + "model": "gpt-4", + "baseUrl": "" + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code", is(200))) .andExpect(jsonPath("$.data").doesNotExist()); - verify(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class)); + verify(settingsService).saveGeneralSettings(argThat(settings -> + "sk-new".equals(settings.getApiKey()) && settings.isApiKeyConfigured())); + } + + @Test + void saveGeneralSettingsShouldAcceptExplicitApiKeyClearWithoutBindingResponseState() throws Exception { + doNothing().when(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class)); + + mockMvc.perform(post("/api/settings/general/save") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "theme": "light", + "compact": false, + "desktopNotify": true, + "notifySound": false, + "sessionTimeout": 60, + "requireLogin": true, + "llmProvider": "openai", + "clearApiKey": true, + "apiKeyConfigured": true, + "model": "gpt-4", + "baseUrl": "" + } + """)) + .andExpect(status().isOk()); + + verify(settingsService).saveGeneralSettings(argThat(settings -> + settings.isClearApiKey() && !settings.isApiKeyConfigured())); + } + + @Test + void saveGeneralSettingsShouldRejectIncompleteReplacement() throws Exception { + mockMvc.perform(post("/api/settings/general/save") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "theme": "light", + "apiKey": "sk-new" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(settingsService); } @Test diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java index 430021af..024a9a75 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java @@ -68,17 +68,68 @@ class SettingsServiceTest { } @Test - void saveGeneralSettingsShouldDelegateToRepository() { - GeneralSettingsVO settings = GeneralSettingsVO.builder() + void saveGeneralSettingsShouldPreserveExistingApiKeyWhenOmitted() { + GeneralSettingsVO existing = GeneralSettingsVO.builder() + .apiKey("sk-existing") + .build(); + GeneralSettingsVO update = GeneralSettingsVO.builder() .theme("light") .compact(false) .sessionTimeout(60) .build(); - doNothing().when(settingsRepository).saveGeneralSettings(settings); + when(settingsRepository.loadGeneralSettings()).thenReturn(existing); + + settingsService.saveGeneralSettings(update); + + assertThat(update.getApiKey()).isEqualTo("sk-existing"); + verify(settingsRepository).saveGeneralSettings(update); + } + + @Test + void saveGeneralSettingsShouldReplaceExistingApiKey() { + GeneralSettingsVO existing = GeneralSettingsVO.builder() + .apiKey("sk-existing") + .build(); + GeneralSettingsVO update = GeneralSettingsVO.builder() + .apiKey("sk-new") + .build(); + when(settingsRepository.loadGeneralSettings()).thenReturn(existing); + + settingsService.saveGeneralSettings(update); + + assertThat(update.getApiKey()).isEqualTo("sk-new"); + verify(settingsRepository).saveGeneralSettings(update); + } + + @Test + void saveGeneralSettingsShouldClearApiKeyOnlyWhenExplicitlyRequested() { + GeneralSettingsVO existing = GeneralSettingsVO.builder() + .apiKey("sk-existing") + .build(); + GeneralSettingsVO update = GeneralSettingsVO.builder() + .clearApiKey(true) + .build(); + when(settingsRepository.loadGeneralSettings()).thenReturn(existing); + + settingsService.saveGeneralSettings(update); + + assertThat(update.getApiKey()).isEmpty(); + assertThat(update.isClearApiKey()).isFalse(); + verify(settingsRepository).saveGeneralSettings(update); + } + + @Test + void saveGeneralSettingsShouldLetClearTakePrecedenceOverReplacementApiKey() { + GeneralSettingsVO update = GeneralSettingsVO.builder() + .apiKey("sk-new") + .clearApiKey(true) + .build(); - settingsService.saveGeneralSettings(settings); + settingsService.saveGeneralSettings(update); - verify(settingsRepository).saveGeneralSettings(settings); + assertThat(update.getApiKey()).isEmpty(); + assertThat(update.isClearApiKey()).isFalse(); + verify(settingsRepository).saveGeneralSettings(update); } @Test diff --git a/web/src/api/generalSettings.test.ts b/web/src/api/generalSettings.test.ts index 1a0700cf..b8928fb0 100644 --- a/web/src/api/generalSettings.test.ts +++ b/web/src/api/generalSettings.test.ts @@ -19,7 +19,7 @@ import MockAdapter from 'axios-mock-adapter'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import client from './client'; import { getGeneralSettings, saveGeneralSettings } from './settings'; -import type { GeneralSettings } from './settings'; +import type { GeneralSettings, GeneralSettingsUpdate } from './settings'; const mock = new MockAdapter(client); const settings: GeneralSettings = { @@ -30,10 +30,21 @@ const settings: GeneralSettings = { sessionTimeout: 60, requireLogin: true, llmProvider: 'openai', - apiKey: 'sk-test', + apiKeyConfigured: true, model: 'gpt-5', baseUrl: 'https://api.example.com/v1', }; +const editableSettings: GeneralSettingsUpdate = { + theme: settings.theme, + compact: settings.compact, + desktopNotify: settings.desktopNotify, + notifySound: settings.notifySound, + sessionTimeout: settings.sessionTimeout, + requireLogin: settings.requireLogin, + llmProvider: settings.llmProvider, + model: settings.model, + baseUrl: settings.baseUrl, +}; describe('general settings API', () => { beforeEach(() => { @@ -52,12 +63,38 @@ describe('general settings API', () => { await expect(getGeneralSettings()).resolves.toEqual(settings); }); - it('persists all editable settings fields', async () => { + it('sends a replacement API key without requiring the stored secret', async () => { + const update: GeneralSettingsUpdate = { ...editableSettings, apiKey: 'sk-new' }; + mock.onPost('/settings/general/save').reply((config) => { + expect(JSON.parse(config.data)).toEqual(update); + return [200, { code: 200, data: null }]; + }); + + await expect(saveGeneralSettings(update)).resolves.toBeUndefined(); + }); + + it('omits blank and response-only API key fields when preserving the stored secret', async () => { + const update = { + ...settings, + apiKey: ' ', + } as GeneralSettingsUpdate & { apiKeyConfigured: boolean }; + mock.onPost('/settings/general/save').reply((config) => { + const body = JSON.parse(config.data); + expect(body).not.toHaveProperty('apiKey'); + expect(body).not.toHaveProperty('apiKeyConfigured'); + return [200, { code: 200, data: null }]; + }); + + await expect(saveGeneralSettings(update)).resolves.toBeUndefined(); + }); + + it('sends the explicit API key clear flag', async () => { + const update: GeneralSettingsUpdate = { ...editableSettings, clearApiKey: true }; mock.onPost('/settings/general/save').reply((config) => { - expect(JSON.parse(config.data)).toEqual(settings); + expect(JSON.parse(config.data)).toEqual(update); return [200, { code: 200, data: null }]; }); - await expect(saveGeneralSettings(settings)).resolves.toBeUndefined(); + await expect(saveGeneralSettings(update)).resolves.toBeUndefined(); }); }); diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index e8b67e64..19829f70 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -26,11 +26,16 @@ export interface GeneralSettings { sessionTimeout: number; requireLogin: boolean; llmProvider: string; - apiKey: string; + apiKeyConfigured: boolean; model: string; baseUrl: string; } +export type GeneralSettingsUpdate = Omit<GeneralSettings, 'apiKeyConfigured'> & { + apiKey?: string; + clearApiKey?: boolean; +}; + export interface DataSource { key: string; name: string; @@ -46,8 +51,11 @@ export async function getGeneralSettings() { return res.data.data; } -export async function saveGeneralSettings(data: Partial<GeneralSettings>) { - await client.post('/settings/general/save', data); +export async function saveGeneralSettings(data: GeneralSettingsUpdate) { + const payload = { ...data } as GeneralSettingsUpdate & { apiKeyConfigured?: boolean }; + delete payload.apiKeyConfigured; + if (!payload.apiKey?.trim()) delete payload.apiKey; + await client.post('/settings/general/save', payload); } // ─── Data Sources ─────────────────────────────────────────────── diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 2e5a3218..9344eea0 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from 'react'; import { Button, + Checkbox, Descriptions, Divider, Flex, @@ -50,7 +51,7 @@ import PageHeader from '../../components/PageHeader'; import { useLang } from '../../i18n/LangContext'; import StatusBadge from '../../components/StatusBadge'; import { getGeneralSettings, saveGeneralSettings } from '../../api/settings'; -import type { GeneralSettings } from '../../api/settings'; +import type { GeneralSettingsUpdate } from '../../api/settings'; import { createDataSource, deleteDataSource, @@ -72,15 +73,20 @@ const typeTagColor: Record<string, string> = { // ─── General Settings Tab ─────────────────────────────────────────────────── const GeneralSettingsTab = () => { - const [form] = Form.useForm(); + const [form] = Form.useForm<GeneralSettingsUpdate>(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [apiKeyConfigured, setApiKeyConfigured] = useState(false); + const clearApiKey = Form.useWatch('clearApiKey', form); useEffect(() => { let cancelled = false; void getGeneralSettings() .then((settings) => { - if (!cancelled) form.setFieldsValue(settings); + if (!cancelled) { + setApiKeyConfigured(settings.apiKeyConfigured); + form.setFieldsValue({ ...settings, apiKey: undefined, clearApiKey: false }); + } }) .catch(() => { if (!cancelled) message.error('通用设置加载失败,请稍后重试'); @@ -94,10 +100,14 @@ const GeneralSettingsTab = () => { }; }, [form]); - const handleFinish = async (values: GeneralSettings) => { + const handleFinish = async (values: GeneralSettingsUpdate) => { setSaving(true); try { await saveGeneralSettings(values); + setApiKeyConfigured( + values.clearApiKey ? false : apiKeyConfigured || Boolean(values.apiKey?.trim()), + ); + form.setFieldsValue({ apiKey: undefined, clearApiKey: false }); message.success('设置已保存'); } catch { message.error('设置保存失败,请稍后重试'); @@ -187,10 +197,26 @@ const GeneralSettingsTab = () => { /> </Form.Item> - <Form.Item label="API Key" name="apiKey"> - <Input.Password placeholder="sk-..." /> + <Form.Item + label="API Key" + name="apiKey" + extra={apiKeyConfigured ? '已配置;留空将保留现有密钥' : '尚未配置'} + > + <Input.Password placeholder="sk-..." disabled={clearApiKey} /> </Form.Item> + {apiKeyConfigured && ( + <Form.Item name="clearApiKey" valuePropName="checked" wrapperCol={{ offset: 4, span: 14 }}> + <Checkbox + onChange={(event) => { + if (event.target.checked) form.setFieldValue('apiKey', undefined); + }} + > + 清除已保存的 API Key + </Checkbox> + </Form.Item> + )} + <Form.Item label="模型名称" name="model"> <Input placeholder="qwen-max" /> </Form.Item>
