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]);
}
});