This is an automated email from the ASF dual-hosted git repository.
guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 8b7bed277 fix: redact plugin secrets in read-only views (#3455)
8b7bed277 is described below
commit 8b7bed277c1e90071605f92a416e78c3bc34d5c6
Author: Yuhan <[email protected]>
AuthorDate: Wed Jul 29 22:06:45 2026 +0800
fix: redact plugin secrets in read-only views (#3455)
---
.../secrets.plugin-view-redaction.spec.ts | 96 ++++++++++++++++++++++
.../FormItemPlugins/PluginEditorDrawer.tsx | 48 +++++++++--
.../form-slice/FormItemPlugins/redact.test.ts | 94 +++++++++++++++++++++
.../form-slice/FormItemPlugins/redact.ts | 62 ++++++++++++++
.../form-slice/FormPartSSL/FormItemCertKeyList.tsx | 4 +
src/locales/de/common.json | 4 +
src/locales/en/common.json | 4 +
src/locales/es/common.json | 4 +
src/locales/tr/common.json | 4 +
src/locales/zh/common.json | 4 +
10 files changed, 319 insertions(+), 5 deletions(-)
diff --git a/e2e/tests/regression/secrets.plugin-view-redaction.spec.ts
b/e2e/tests/regression/secrets.plugin-view-redaction.spec.ts
new file mode 100644
index 000000000..d2ebf4fbb
--- /dev/null
+++ b/e2e/tests/regression/secrets.plugin-view-redaction.spec.ts
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+
+// Regression: a credential's key-auth key was rendered verbatim in the
+// plugin card's read-only View drawer. The mask is driven by the gateway's
+// own `encrypt_fields` (key-auth declares `["key"]`), not by a list kept
+// here.
+//
+// Related issue:
+// - apache/apisix-dashboard#3416 plugin secrets leak in the read-only
+// plugin view
+
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const USERNAME = 'reg_redaction_consumer';
+const CREDENTIAL = 'reg-redaction-cred';
+const SECRET = 'SUPER-SECRET-KEY-12345';
+
+test.beforeAll(async () => {
+ await putConsumerReq(e2eReq, {
+ username: USERNAME,
+ } as APISIXType['ConsumerPut']);
+ await e2eReq.put(`/consumers/${USERNAME}/credentials/${CREDENTIAL}`, {
+ plugins: { 'key-auth': { key: SECRET } },
+ });
+});
+
+test.afterAll(async () => {
+ await deleteAllConsumers(e2eReq);
+});
+
+test('the read-only plugin view hides the secret until asked', async ({
+ page,
+}) => {
+ await uiGoto(
+ page,
+ '/consumers/detail/$username/credentials/detail/$id',
+ { username: USERNAME, id: CREDENTIAL }
+ );
+
+ await page.getByRole('button', { name: 'View', exact: true }).click();
+ const drawer = page.getByRole('dialog');
+ await expect(drawer).toBeVisible();
+
+ await expect(drawer).toContainText('••••••');
+ await expect(page.locator('body')).not.toContainText(SECRET);
+
+ // View mode must offer no way to write the redacted text back.
+ await expect(drawer.getByRole('button', { name: 'Save' })).toHaveCount(0);
+
+ await page.getByRole('button', { name: 'Show secrets' }).click();
+ await expect(drawer).toContainText(SECRET);
+});
+
+// The catastrophic direction is the opposite one: if redaction ever leaked
+// into a mutable mode, saving would write `••••••` over a live credential.
+// `displayConfig`'s ternary is what prevents it, and nothing else pins that.
+test('edit mode receives the real config, never the redacted one', async ({
+ page,
+}) => {
+ await uiGoto(
+ page,
+ '/consumers/detail/$username/credentials/detail/$id',
+ { username: USERNAME, id: CREDENTIAL }
+ );
+
+ // The plugin card shows View while the page is read-only and Edit once
+ // the page form is editable, so the page's Edit must be clicked first.
+ await page.getByRole('button', { name: 'Edit', exact: true }).click();
+ await page.getByRole('button', { name: 'Edit', exact: true }).last().click();
+
+ const drawer = page.getByRole('dialog');
+ await expect(drawer).toBeVisible();
+ await expect(drawer).toContainText(SECRET);
+ await expect(drawer).not.toContainText('••••••');
+});
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index cf3e8827c..f72e07685 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -14,11 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { Drawer, Group, Text, Title } from '@mantine/core';
+import { Button, Drawer, Group, Text, Title } from '@mantine/core';
import { modals } from '@mantine/modals';
import { isAxiosError } from 'axios';
import { isEmpty, isNil } from 'rambdax';
-import { useEffect } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -27,6 +27,7 @@ import { FormSubmitBtn } from '@/components/form/Btn';
import { FormItemEditor } from '@/components/form/Editor';
import type { PluginCardListProps } from './PluginCardList';
+import { redactByPaths } from './redact';
// PluginConfig is defined in the API layer (apis/plugins) and re-exported
// here so existing importers keep their path.
@@ -46,10 +47,28 @@ export const PluginEditorDrawer = (props:
PluginEditorDrawerProps) => {
const { opened, onSave, onClose, plugin, mode, schema } = props;
const { name, config } = plugin;
const { t } = useTranslation();
+ // The gateway tells us which fields are secret — `encrypt_fields` rides
+ // along inside the schema object the drawer already receives, so there is
+ // no list to maintain here and nothing extra to fetch.
+ const encryptFields = useMemo(
+ () => (schema as { encrypt_fields?: string[] })?.encrypt_fields ?? [],
+ [schema]
+ );
+ const [revealed, setRevealed] = useState(false);
+ const redacted = useMemo(
+ () => redactByPaths(config, encryptFields),
+ [config, encryptFields]
+ );
+ // Only offer the toggle when redaction actually changed something: a
+ // control that promises to reveal secrets on a plugin that has none is a
+ // lie about the data.
+ const hasSecrets = toConfigStr(redacted as object) !== toConfigStr(config);
+ const displayConfig =
+ mode === 'view' && hasSecrets && !revealed ? (redacted as object) : config;
const methods = useForm<{ config: string }>({
criteriaMode: 'all',
disabled: mode === 'view',
- defaultValues: { config: toConfigStr(config) },
+ defaultValues: { config: toConfigStr(displayConfig) },
});
const handleClose = () => {
if (mode !== 'view' && methods.getValues('config') !==
toConfigStr(config)) {
@@ -70,8 +89,14 @@ export const PluginEditorDrawer = (props:
PluginEditorDrawerProps) => {
};
useEffect(() => {
- methods.setValue('config', toConfigStr(config));
- }, [config, methods]);
+ methods.setValue('config', toConfigStr(displayConfig));
+ }, [displayConfig, methods]);
+
+ // A reopened drawer must start redacted; otherwise one reveal leaks into
+ // every later view.
+ useEffect(() => {
+ if (!opened) setRevealed(false);
+ }, [opened]);
return (
<Drawer
@@ -92,6 +117,19 @@ export const PluginEditorDrawer = (props:
PluginEditorDrawerProps) => {
{name}
</Title>
<FormProvider {...methods}>
+ {mode === 'view' && hasSecrets && (
+ <Button
+ mb={10}
+ size="compact-xs"
+ variant="light"
+ aria-pressed={revealed}
+ onClick={() => setRevealed((v) => !v)}
+ >
+ {revealed
+ ? t('form.plugins.hideSecrets')
+ : t('form.plugins.showSecrets')}
+ </Button>
+ )}
<form>
<FormItemEditor
name="config"
diff --git a/src/components/form-slice/FormItemPlugins/redact.test.ts
b/src/components/form-slice/FormItemPlugins/redact.test.ts
new file mode 100644
index 000000000..8c4117a6e
--- /dev/null
+++ b/src/components/form-slice/FormItemPlugins/redact.test.ts
@@ -0,0 +1,94 @@
+/**
+ * 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.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { redactByPaths, REDACTED } from './redact';
+
+describe('redactByPaths', () => {
+ it('masks a top-level path', () => {
+ expect(redactByPaths({ key: 'SECRET', header: 'apikey' },
['key'])).toEqual({
+ key: REDACTED,
+ header: 'apikey',
+ });
+ });
+
+ it('masks a nested path', () => {
+ const cfg = { session: { redis: { password: 'p' }, secret: 's' } };
+ expect(redactByPaths(cfg, ['session.redis.password'])).toEqual({
+ session: { redis: { password: REDACTED }, secret: 's' },
+ });
+ });
+
+ // `brokers` is `type: array` in the kafka-logger schema, and the gateway
+ // still emits a flat dotted path. Walking it as an object property finds
+ // nothing and silently leaves the password in plaintext — a failure that
+ // looks exactly like success.
+ it('applies the remaining path to EVERY element of an array', () => {
+ const cfg = {
+ brokers: [
+ { host: 'a', sasl_config: { password: 'first' } },
+ { host: 'b', sasl_config: { password: 'second' } },
+ ],
+ };
+ expect(redactByPaths(cfg, ['brokers.sasl_config.password'])).toEqual({
+ brokers: [
+ { host: 'a', sasl_config: { password: REDACTED } },
+ { host: 'b', sasl_config: { password: REDACTED } },
+ ],
+ });
+ });
+
+ it('ignores a declared path the config does not contain', () => {
+ expect(redactByPaths({ header: 'apikey' }, ['key'])).toEqual({
+ header: 'apikey',
+ });
+ });
+
+ it('does not mutate the input', () => {
+ const cfg = { key: 'SECRET' };
+ redactByPaths(cfg, ['key']);
+ expect(cfg).toEqual({ key: 'SECRET' });
+ });
+
+ it('replaces a non-string leaf too', () => {
+ expect(redactByPaths({ auth: { conf: { a: 1 } } },
['auth.conf'])).toEqual({
+ auth: { conf: REDACTED },
+ });
+ });
+
+ it('returns the input unchanged when there are no paths', () => {
+ expect(redactByPaths({ key: 'SECRET' }, [])).toEqual({ key: 'SECRET' });
+ });
+
+ // `encrypt_fields` rides in over the network as part of the plugin
+ // schema, so a malformed value must not crash the drawer's render.
+ it('ignores a non-array paths value', () => {
+ const cfg = { key: 'SECRET' };
+ expect(redactByPaths(cfg, 'key')).toEqual({ key: 'SECRET' });
+ expect(redactByPaths(cfg, null)).toEqual({ key: 'SECRET' });
+ expect(redactByPaths(cfg, { 0: 'key' })).toEqual({ key: 'SECRET' });
+ });
+
+ it('skips a non-string element but still applies the valid ones', () => {
+ expect(
+ redactByPaths({ key: 'SECRET', header: 'apikey' }, [42, 'key', null])
+ ).toEqual({
+ key: REDACTED,
+ header: 'apikey',
+ });
+ });
+});
diff --git a/src/components/form-slice/FormItemPlugins/redact.ts
b/src/components/form-slice/FormItemPlugins/redact.ts
new file mode 100644
index 000000000..27f92459a
--- /dev/null
+++ b/src/components/form-slice/FormItemPlugins/redact.ts
@@ -0,0 +1,62 @@
+/**
+ * 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.
+ */
+
+/** Placeholder shown in place of a secret. Fixed width: the length of a
+ * secret is itself a clue to its strength, so it must not leak. */
+export const REDACTED = '••••••';
+
+const isPlainObject = (v: unknown): v is Record<string, unknown> =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+const redactPath = (node: unknown, segments: string[]): unknown => {
+ if (segments.length === 0) return REDACTED;
+
+ // The gateway emits flat dotted paths even when a segment crosses an
+ // array (kafka-logger `brokers.sasl_config.password`, ai-proxy-multi
+ // `instances.auth.header`), so an array means "apply to every element".
+ if (Array.isArray(node)) {
+ return node.map((item) => redactPath(item, segments));
+ }
+
+ if (!isPlainObject(node)) return node;
+
+ const [head, ...rest] = segments;
+ if (!(head in node)) return node;
+
+ return { ...node, [head]: redactPath(node[head], rest) };
+};
+
+/**
+ * Replace every value named by `paths` with {@link REDACTED}.
+ *
+ * `paths` come from the plugin schema's `encrypt_fields`, i.e. the gateway
+ * decides what is sensitive — the dashboard keeps no list of its own. Since
+ * that arrives over the network as `unknown` in practice, a non-array is
+ * ignored and any non-string element is skipped, rather than trusting the
+ * declared `string[]` shape.
+ * Returns a new value; the input is left intact so edit mode still has the
+ * real config.
+ */
+export const redactByPaths = (config: unknown, paths: unknown): unknown => {
+ if (!Array.isArray(paths)) return config;
+
+ return paths.reduce<unknown>(
+ (acc, path) =>
+ typeof path === 'string' ? redactPath(acc, path.split('.')) : acc,
+ config
+ );
+};
diff --git a/src/components/form-slice/FormPartSSL/FormItemCertKeyList.tsx
b/src/components/form-slice/FormPartSSL/FormItemCertKeyList.tsx
index 96d77e574..ac087a6bd 100644
--- a/src/components/form-slice/FormPartSSL/FormItemCertKeyList.tsx
+++ b/src/components/form-slice/FormPartSSL/FormItemCertKeyList.tsx
@@ -53,6 +53,8 @@ const RequiredCertKey = () => {
control={control}
label={`${t('form.ssls.key')} 1`}
name="key"
+ placeholder={t('form.ssls.keyHidden')}
+ description={t('form.ssls.keyHint')}
required
/>
</PairWrapper>
@@ -99,6 +101,8 @@ const CertKeyPairList = () => {
key={keys.fields[idx].id}
name={`keys.${idx}`}
label={`${t('form.ssls.key')} ${idx + 2}`}
+ placeholder={t('form.ssls.keyHidden')}
+ description={t('form.ssls.keyHint')}
/>
</PairWrapper>
))}
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 99bcbf61b..ee9724bdc 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -62,11 +62,13 @@
"addPlugin": "Plugin hinzufügen",
"configId": "Plugin-Konfigurations-ID",
"editPlugin": "Plugin bearbeiten",
+ "hideSecrets": "Geheimnisse verbergen",
"label": "Plugins",
"searchForSelectedPlugins": "Nach ausgewählten Plugins suchen",
"selectPlugins": {
"title": "Plugins auswählen"
},
+ "showSecrets": "Geheimnisse anzeigen",
"viewPlugin": "Plugin anzeigen"
},
"protos": {
@@ -139,6 +141,8 @@
"title": "Client"
},
"key": "Private Key",
+ "keyHidden": "••••••",
+ "keyHint": "Das Gateway gibt private Schlüssel nie zurück. Geben Sie den
Schlüssel erneut ein, um Änderungen zu speichern.",
"sni": "SNI",
"snis": "SNIs",
"ssl_protocols": "SSL Protokolle",
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 953d58c4f..5dcc698f4 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -62,11 +62,13 @@
"addPlugin": "Add Plugin",
"configId": "Plugin Config ID",
"editPlugin": "Edit Plugin",
+ "hideSecrets": "Hide secrets",
"label": "Plugins",
"searchForSelectedPlugins": "Search for Selected Plugins",
"selectPlugins": {
"title": "Select Plugins"
},
+ "showSecrets": "Show secrets",
"viewPlugin": "View Plugin"
},
"protos": {
@@ -139,6 +141,8 @@
"title": "Client"
},
"key": "Private Key",
+ "keyHidden": "••••••",
+ "keyHint": "The gateway never returns private keys. Enter the key again
to save any change.",
"sni": "SNI",
"snis": "SNIs",
"ssl_protocols": "SSL Protocols",
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 9bf152463..559c55d82 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -62,11 +62,13 @@
"addPlugin": "Añadir Plugin",
"configId": "ID de Configuración de Plugin",
"editPlugin": "Editar Plugin",
+ "hideSecrets": "Ocultar secretos",
"label": "Plugins",
"searchForSelectedPlugins": "Buscar Plugins Seleccionados",
"selectPlugins": {
"title": "Seleccionar Plugins"
},
+ "showSecrets": "Mostrar secretos",
"viewPlugin": "Ver Plugin"
},
"protos": {
@@ -139,6 +141,8 @@
"title": "Cliente"
},
"key": "Clave Privada",
+ "keyHidden": "••••••",
+ "keyHint": "La puerta de enlace nunca devuelve claves privadas.
Introduzca la clave de nuevo para guardar cualquier cambio.",
"sni": "SNI",
"snis": "SNIs",
"ssl_protocols": "Protocolos SSL",
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 2f903497a..bf257a267 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -62,11 +62,13 @@
"addPlugin": "Plugin Ekle",
"configId": "Plugin Config ID",
"editPlugin": "Plugin Düzenle",
+ "hideSecrets": "Gizli değerleri gizle",
"label": "Plugin'ler",
"searchForSelectedPlugins": "Seçili Plugin'lerde Ara",
"selectPlugins": {
"title": "Plugin Seç"
},
+ "showSecrets": "Gizli değerleri göster",
"viewPlugin": "Plugin'i Görüntüle"
},
"protos": {
@@ -139,6 +141,8 @@
"title": "Client"
},
"key": "Private Key",
+ "keyHidden": "••••••",
+ "keyHint": "Ağ geçidi özel anahtarları asla döndürmez. Değişiklikleri
kaydetmek için anahtarı yeniden girin.",
"sni": "SNI",
"snis": "SNI'ler",
"ssl_protocols": "SSL Protocol'leri",
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index d49f2d94f..c16f67d41 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -62,11 +62,13 @@
"addPlugin": "添加插件",
"configId": "插件配置ID",
"editPlugin": "编辑插件",
+ "hideSecrets": "隐藏密钥",
"label": "插件",
"searchForSelectedPlugins": "搜索所选插件",
"selectPlugins": {
"title": "选择插件"
},
+ "showSecrets": "显示密钥",
"viewPlugin": "查看插件"
},
"protos": {
@@ -139,6 +141,8 @@
"title": "客户端"
},
"key": "私钥",
+ "keyHidden": "••••••",
+ "keyHint": "网关不会返回私钥。保存任何修改都需要重新填入私钥。",
"sni": "SNI",
"snis": "SNI 列表",
"ssl_protocols": "SSL协议",