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 b170cd230 fix: guard rmDoubleUnderscoreKeys against null values (#3432)
b170cd230 is described below

commit b170cd2302bce9536707f33cbae21f8c1e2916a0
Author: Yuhan <[email protected]>
AuthorDate: Mon Jul 20 16:57:12 2026 +0800

    fix: guard rmDoubleUnderscoreKeys against null values (#3432)
---
 .../form.plugin-config-null-submit.spec.ts         | 89 ++++++++++++++++++++++
 src/utils/producer.test.ts                         | 79 +++++++++++++++++++
 src/utils/producer.ts                              |  8 +-
 3 files changed, 175 insertions(+), 1 deletion(-)

diff --git a/e2e/tests/regression/form.plugin-config-null-submit.spec.ts 
b/e2e/tests/regression/form.plugin-config-null-submit.spec.ts
new file mode 100644
index 000000000..76c12221f
--- /dev/null
+++ b/e2e/tests/regression/form.plugin-config-null-submit.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * 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 for a data-integrity item of apache/apisix-dashboard#3417:
+// `rmDoubleUnderscoreKeys` recursed into null values (`typeof null ===
+// 'object'`) and threw at `Object.keys(null)`. It runs FIRST in
+// pipeProduce — before the null-cleaner — so a null anywhere in the
+// submit draft crashed the submit of every pipeProduce resource: no PUT
+// was sent and (since the toast layer lives in the axios interceptor,
+// which never ran) there was zero user feedback. The Admin API accepts
+// nulls inside plugin configs, so such a resource could not be edited
+// from the dashboard at all.
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('edit-save succeeds for a route whose plugin config contains null', async 
({
+  page,
+}) => {
+  const name = randomId('reg-null-plugin');
+  const res = await e2eReq.put<{ value: APISIXType['Route'] }>(
+    `/routes/${name}`,
+    {
+      name,
+      uri: `/reg-null-plugin/${name}`,
+      plugins: { 'key-auth': { custom_note: null } },
+      upstream: { type: 'roundrobin', nodes: { 'null-plugin.local:80': 1 } },
+    }
+  );
+  const id = res.data.value.id;
+
+  let putCount = 0;
+  page.on('request', (req) => {
+    if (
+      req.method() === 'PUT' &&
+      req.url().includes(`/apisix/admin/routes/${id}`)
+    )
+      putCount += 1;
+  });
+
+  await uiGoto(page, '/routes/detail/$id', { id });
+  await routesPom.isDetailPage(page);
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByRole('button', { name: 'Save' }).click();
+
+  // unfixed, the submit pipeline throws before the request is built:
+  // no PUT fires and no feedback of any kind appears
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+  expect(putCount).toBeGreaterThan(0);
+
+  // the plugin entry must survive the round-trip; the null member's fate
+  // belongs to the existing null-cleaner/empty-plugin-restore semantics
+  const after = await e2eReq.get<{ value: APISIXType['Route'] }>(
+    `/routes/${id}`
+  );
+  expect(after.data.value.plugins?.['key-auth']).toBeTruthy();
+});
diff --git a/src/utils/producer.test.ts b/src/utils/producer.test.ts
new file mode 100644
index 000000000..224530992
--- /dev/null
+++ b/src/utils/producer.test.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 { pipeProduce, rmDoubleUnderscoreKeys } from './producer';
+
+// Regression for a data-integrity item of #3417: `typeof null === 'object'`,
+// so the recursion descended into null values and threw
+// `TypeError: Cannot convert undefined or null to object` at
+// `Object.keys(null)`. The producer runs FIRST in pipeProduce — before the
+// null-cleaner — so any null anywhere in a submit draft (e.g. typed into a
+// plugin's JSON editor, or stored via the Admin API, which accepts nulls in
+// plugin configs) crashed every submit of that resource, with no request
+// sent and no user feedback.
+
+describe('rmDoubleUnderscoreKeys', () => {
+  it('does not throw on null values', () => {
+    const draft = {
+      plugins: { 'key-auth': { custom_note: null } },
+    };
+    expect(() => rmDoubleUnderscoreKeys(draft)).not.toThrow();
+  });
+
+  it('leaves null values in place for the downstream null-cleaner', () => {
+    const draft = {
+      plugins: { 'key-auth': { custom_note: null, header: 'apikey' } },
+    };
+    rmDoubleUnderscoreKeys(draft);
+    expect(draft.plugins['key-auth']).toEqual({
+      custom_note: null,
+      header: 'apikey',
+    });
+  });
+
+  it('still removes __-prefixed keys at every depth', () => {
+    const draft = {
+      __checksEnabled: true,
+      upstream: { __checksPassiveEnabled: false, scheme: 'http' },
+      plugins: { 'key-auth': { custom_note: null, __ui: 1 } },
+    };
+    rmDoubleUnderscoreKeys(draft);
+    expect(draft).toEqual({
+      upstream: { scheme: 'http' },
+      plugins: { 'key-auth': { custom_note: null } },
+    });
+  });
+});
+
+describe('pipeProduce', () => {
+  it('does not throw when a plugin config contains null', () => {
+    const val = {
+      name: 'r1',
+      uri: '/r1',
+      plugins: { 'key-auth': { custom_note: null } },
+      upstream: { type: 'roundrobin', nodes: { 'a.local:80': 1 } },
+    };
+    let produced: typeof val | undefined;
+    expect(() => {
+      produced = pipeProduce()(val);
+    }).not.toThrow();
+    // the plugin entry must survive the pipeline; the null member itself is
+    // owned by the existing null-cleaner / empty-plugin-restore stages
+    expect(produced?.plugins['key-auth']).toBeTruthy();
+  });
+});
diff --git a/src/utils/producer.ts b/src/utils/producer.ts
index 2d4e82e15..ced4ff78d 100644
--- a/src/utils/producer.ts
+++ b/src/utils/producer.ts
@@ -71,7 +71,13 @@ export const rmDoubleUnderscoreKeys = (obj: object) => {
   Object.keys(obj).forEach((key) => {
     const k = key as keyof typeof obj;
     if ((key as string).startsWith('__')) return delete obj[k];
-    if (typeof obj[k] === 'object' && !Array.isArray(obj[k])) {
+    // typeof null === 'object': recursing into null threw at Object.keys.
+    // Nulls are the downstream null-cleaner's job, not ours (#3417).
+    if (
+      typeof obj[k] === 'object' &&
+      obj[k] !== null &&
+      !Array.isArray(obj[k])
+    ) {
       (obj[k] as object) = rmDoubleUnderscoreKeys(obj[k]);
     }
   });

Reply via email to