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协议",

Reply via email to