From a79bc7d074eef0ecaea7e90c95006934db68c953 Mon Sep 17 00:00:00 2001 From: yungle246 Date: Thu, 25 Jun 2026 14:21:38 +0900 Subject: [PATCH] feat(api): mask secret tokens in api-key list responses (reveal-once) Previously the console api-key list returned every key's full plaintext token, so anyone with console access could retrieve the secret of an already-created key (via the copy button or the raw API response). This is contrary to the reveal-once norm. - List endpoints (app keys, workspace dataset keys, per-dataset keys) now return a masked token (prefix + last 4); the full secret is only ever returned by the create endpoint, at creation time. - Frontend secret-key modal displays the masked token as-is and drops the copy affordance for existing keys (copying a masked value is pointless). Applies to both app and dataset keys since they share the modal and the ApiKeyItem response model. --- api/controllers/console/apikey.py | 27 ++++++++++++- api/controllers/console/datasets/datasets.py | 4 +- .../console/datasets/test_datasets.py | 9 +++-- .../controllers/console/test_apikey.py | 38 +++++++++++++++++-- .../__tests__/secret-key-modal.spec.tsx | 34 ++++++++--------- .../develop/secret-key/secret-key-modal.tsx | 14 +++---- 6 files changed, 89 insertions(+), 37 deletions(-) diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 7f6879754ad..2bd050464f9 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from datetime import datetime from typing import override from uuid import UUID @@ -58,6 +59,28 @@ class ApiKeyList(ResponseModel): register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList) +def mask_api_token(token: str) -> str: + """Mask a secret token for list responses. + + Reveal-once: the full secret is only returned by the create endpoint. List + endpoints expose just enough (prefix + last 4) to identify a key, never the + full value, so an existing key's secret cannot be retrieved after creation. + """ + if len(token) <= 8: + return "***" + return f"{token[:5]}...{token[-4:]}" + + +def build_masked_api_key_list(api_tokens: Iterable[ApiToken]) -> ApiKeyList: + """Build an ApiKeyList from ORM tokens with their secrets masked.""" + items: list[ApiKeyItem] = [] + for api_token in api_tokens: + item = ApiKeyItem.model_validate(api_token, from_attributes=True) + item.token = mask_api_token(item.token) + items.append(item) + return ApiKeyList(data=items) + + def _get_resource(resource_id, tenant_id, resource_model): with sessionmaker(db.engine).begin() as session: resource = session.execute( @@ -91,7 +114,7 @@ class BaseApiKeyListResource(Resource): ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id ) ).all() - return ApiKeyList.model_validate({"data": keys}, from_attributes=True) + return build_masked_api_key_list(keys) @edit_permission_required def post(self, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]: @@ -258,7 +281,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource): or_(ApiToken.dataset_id == resource_id, ApiToken.dataset_id.is_(None)), ) ).all() - return ApiKeyList.model_validate({"data": keys}, from_attributes=True) + return build_masked_api_key_list(keys) @console_ns.doc("create_dataset_api_key") @console_ns.doc(description="Create a new API key bound to a single dataset") diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 55bc85483d5..22259611c78 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -13,7 +13,7 @@ from configs import dify_config from controllers.common.fields import ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.apikey import ApiKeyItem, ApiKeyList +from controllers.console.apikey import ApiKeyItem, ApiKeyList, build_masked_api_key_list from controllers.console.app.error import ProviderNotInitializeError from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError from controllers.console.wraps import ( @@ -1007,7 +1007,7 @@ class DatasetApiKeyApi(Resource): keys = db.session.scalars( select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id) ).all() - return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json") + return build_masked_api_key_list(keys).model_dump(mode="json") @console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(400, "Maximum keys exceeded") diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index a4ac0af21c8..a2b1744cafc 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -1759,14 +1759,14 @@ class TestDatasetApiKeyApi: mock_key_1.id = "key-1" mock_key_1.type = "dataset" mock_key_1.dataset_id = None - mock_key_1.token = "ds-abc" + mock_key_1.token = "dataset-aaaa1111bbbb" mock_key_1.last_used_at = None mock_key_1.created_at = None mock_key_2 = MagicMock(spec=ApiToken) mock_key_2.id = "key-2" mock_key_2.type = "dataset" mock_key_2.dataset_id = None - mock_key_2.token = "ds-def" + mock_key_2.token = "dataset-cccc2222dddd" mock_key_2.last_used_at = None mock_key_2.created_at = None @@ -1781,10 +1781,11 @@ class TestDatasetApiKeyApi: assert "data" in response assert len(response["data"]) == 2 + # reveal-once: the list returns masked tokens, never the full secret assert response["data"][0]["id"] == "key-1" - assert response["data"][0]["token"] == "ds-abc" + assert response["data"][0]["token"] == "datas...bbbb" assert response["data"][1]["id"] == "key-2" - assert response["data"][1]["token"] == "ds-def" + assert response["data"][1]["token"] == "datas...dddd" def test_post_create_api_key_success(self, app: Flask): api = DatasetApiKeyApi() diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py index 803fbfaef8d..06fa7a62d70 100644 --- a/api/tests/unit_tests/controllers/console/test_apikey.py +++ b/api/tests/unit_tests/controllers/console/test_apikey.py @@ -9,7 +9,13 @@ from unittest.mock import patch import pytest from werkzeug.exceptions import Forbidden -from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource, DatasetApiKeyListResource +from controllers.console.apikey import ( + BaseApiKeyListResource, + BaseApiKeyResource, + DatasetApiKeyListResource, + build_masked_api_key_list, + mask_api_token, +) from models import Account from models.account import AccountStatus, TenantAccountRole from models.dataset import Dataset @@ -45,12 +51,37 @@ def _make_account(role: TenantAccountRole) -> Account: return account +def test_mask_api_token_reveals_only_a_fragment() -> None: + # full secret must never be reproducible from the masked value + masked = mask_api_token("dataset-mqxAkpML14jRmgsb6Z7DBnVq") + assert masked == "datas...BnVq" + assert "mqxAkpML" not in masked + # very short tokens are fully hidden + assert mask_api_token("short") == "***" + + +def test_build_masked_api_key_list_masks_every_token() -> None: + keys = [ + SimpleNamespace( + id="key-1", + type=ApiTokenType.DATASET, + token="dataset-aaaabbbbccccdddd", + dataset_id="ds-1", + last_used_at=None, + created_at=None, + ), + ] + result = build_masked_api_key_list(keys) + assert result.data[0].token == "datas...dddd" + assert result.data[0].dataset_id == "ds-1" + + def test_list_api_keys_uses_injected_tenant_id() -> None: resource = _make_list_resource() api_key = SimpleNamespace( id="key-1", type=ApiTokenType.APP, - token="app-token", + token="app-1234567890abcdef", last_used_at=None, created_at=None, ) @@ -68,8 +99,9 @@ def test_list_api_keys_uses_injected_tenant_id() -> None: "data": [ { "id": "key-1", + # reveal-once: the list never returns the full secret, only a masked fragment "type": "app", - "token": "app-token", + "token": "app-1...cdef", "dataset_id": None, "last_used_at": None, "created_at": None, diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index a675bf7bb31..9344011c602 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -177,7 +177,7 @@ describe('SecretKeyModal', () => { it('should render API keys when available', async () => { await renderModal() - expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() + expect(screen.getByText('sk-abc123def456ghi789')).toBeInTheDocument() }) it('should render created time for keys', async () => { @@ -236,7 +236,7 @@ describe('SecretKeyModal', () => { it('should render dataset API keys when no appId', async () => { await renderModal() - expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument() + expect(screen.getByText('dk-abc123def456ghi789')).toBeInTheDocument() }) }) @@ -445,14 +445,14 @@ describe('SecretKeyModal', () => { it('should render delete button for permitted users', async () => { await renderModal() - const actionButtons = screen.getAllByRole('button') - expect(actionButtons.length).toBeGreaterThanOrEqual(3) + const actionButtons = document.body.querySelectorAll('button.action-btn') + expect(actionButtons.length).toBeGreaterThanOrEqual(1) }) it('should render API key row with actions', async () => { await renderModal() - expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument() + expect(screen.getByText('sk-abc123def456ghi789')).toBeInTheDocument() }) it('should have action buttons in the key row', async () => { @@ -475,7 +475,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] expect(deleteButton).toBeInTheDocument() await act(async () => { @@ -495,7 +495,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -525,7 +525,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -552,7 +552,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -581,7 +581,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -617,7 +617,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -652,7 +652,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -697,7 +697,7 @@ describe('SecretKeyModal', () => { await renderModal() const actionButtons = document.body.querySelectorAll('button.action-btn') - const deleteButton = actionButtons[1] + const deleteButton = actionButtons[0] await act(async () => { await user.click(deleteButton!) vi.runAllTimers() @@ -720,16 +720,16 @@ describe('SecretKeyModal', () => { }) }) - describe('token truncation', () => { - it('should truncate token correctly', async () => { + describe('token display', () => { + it('should display the token exactly as provided by the API (no client-side reveal)', async () => { const apiKeys = [ - { id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null }, + { id: 'key-1', token: 'sk-...7890', created_at: 1700000000, last_used_at: null }, ] mockAppApiKeysData.mockReturnValue({ data: apiKeys }) await renderModal() - expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument() + expect(screen.getByText('sk-...7890')).toBeInTheDocument() }) }) diff --git a/web/app/components/develop/secret-key/secret-key-modal.tsx b/web/app/components/develop/secret-key/secret-key-modal.tsx index e30c0cc84ba..a6e842e5825 100644 --- a/web/app/components/develop/secret-key/secret-key-modal.tsx +++ b/web/app/components/develop/secret-key/secret-key-modal.tsx @@ -19,7 +19,6 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import CopyFeedback from '@/app/components/base/copy-feedback' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' import useTimestamp from '@/hooks/use-timestamp' @@ -122,10 +121,6 @@ const SecretKeyModal = ({ : t('apiKeyModal.scopeBoundDataset', { ns: 'appApi' }) } - const generateToken = (token: string) => { - return `${token.slice(0, 3)}...${token.slice(-20)}` - } - const handleDeleteConfirmOpenChange = (open: boolean) => { if (open) return @@ -154,7 +149,9 @@ const SecretKeyModal = ({
- +

{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}

{isApiKeysLoading &&
} @@ -171,12 +168,11 @@ const SecretKeyModal = ({
{apiKeysList.data.map(api => (
-
{generateToken(api.token)}
+
{api.token}
{!appId &&
{getScopeLabel(api.dataset_id)}
}
{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}
{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}
-
- +
{canManage && ( {