From cfa84783f338e4ff3338287dfc5f5c5fe1417ca2 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 30 May 2026 11:48:54 +0200 Subject: [PATCH 1/2] Stop persisting admin key to localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/stores/global.ts currently exports `adminKeyAtom` as `atomWithStorage('settings:adminKey', '', undefined, { getOnInit: true })`. With the 3rd argument left as `undefined`, jotai's `atomWithStorage` defaults to `createJSONStorage(() => localStorage)`, so the admin key is persisted to localStorage under `settings:adminKey` and re-loaded on init. This widens the admin credential's audience to anything that can read the dashboard origin's storage: XSS on the dashboard origin, browser extensions, shared workstations, anyone with filesystem access to the browser profile. The admin key authorises full Apache APISIX control-plane mutation; that audience is too broad for the credential's authority. Replace `atomWithStorage` with plain `atom('')` so the key lives only in-memory for the duration of the browser session. Operators re-enter the key each session; the previous behaviour can be restored on a per-deployment basis by an operator-supplied browser autofill, password manager, or session-replay tooling — but none of those should be the default. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/stores/global.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/stores/global.ts b/src/stores/global.ts index 0b0e5a450e..094718c529 100644 --- a/src/stores/global.ts +++ b/src/stores/global.ts @@ -15,17 +15,12 @@ * limitations under the License. */ import { atom } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; -// Admin key with persistent storage -export const adminKeyAtom = atomWithStorage( - 'settings:adminKey', - '', - undefined, - { - getOnInit: true, - } -); +// Admin key — in-memory only. Operators re-enter the key each browser +// session by design: persisting it to localStorage broadens the +// credential's audience to anything that can read the dashboard origin's +// storage (XSS, browser extensions, shared workstations). +export const adminKeyAtom = atom(''); // Settings modal visibility state export const isSettingsOpenAtom = atom(false); From 1707b21418a67f40d8a62972e61a63bd154b4662 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 2 Jun 2026 17:43:18 +0200 Subject: [PATCH 2/2] e2e: authenticate via Admin API header injection (in-memory admin key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin key is now held in memory only, so it can no longer be replayed through Playwright's storageState (which only captures cookies + localStorage). The e2e suite navigates with page.goto (full document loads), which dropped the in-memory key on every navigation and left the dashboard unauthenticated — failing auth.spec and every CRUD spec. Replace the storageState worker fixture with a context-level route that injects the X-API-KEY header on every Admin API request, keeping the app authenticated across reloads/navigations without touching production code. auth.spec.ts opts out and now asserts the key is NOT persisted across a full reload. Generated-by: Claude Opus 4.8 (1M context) --- e2e/tests/auth.spec.ts | 17 +++++++-- e2e/utils/test.ts | 86 +++++++++++++++++++----------------------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index ef911cbba0..c55e046911 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -19,8 +19,9 @@ import { getAPISIXConf } from '@e2e/utils/common'; import { test } from '@e2e/utils/test'; import { expect } from '@playwright/test'; -// use empty storage state to avoid auth -test.use({ storageState: { cookies: [], origins: [] } }); +// This suite exercises the manual auth flow itself, so opt out of the +// automatic Admin API key injection and drive the Settings modal directly. +test.use({ injectAdminKey: false }); test('can auth with admin key', { tag: '@auth' }, async ({ page }) => { const settingsModal = page.getByRole('dialog', { name: 'Settings' }); @@ -60,9 +61,19 @@ test('can auth with admin key', { tag: '@auth' }, async ({ page }) => { .getByRole('button') .click(); - await page.reload(); + // The key authenticates the current session immediately (it is held in + // memory), so the token check now succeeds without a reload. await expect(failedMsg).toBeHidden(); }); + + await test.step('admin key is not persisted across a full reload', async () => { + // The admin key is kept in memory only and never written to browser + // storage, so a hard reload drops it and re-authentication is required. + await page.reload(); + await expect(failedMsg).toBeVisible(); + await expect(settingsModal).toBeVisible(); + await expect(adminKeyInput).toBeEmpty(); + }); }); test('password input can toggle visibility', { tag: '@auth' }, async ({ page }) => { diff --git a/e2e/utils/test.ts b/e2e/utils/test.ts index 690d78de58..909d90f83c 100644 --- a/e2e/utils/test.ts +++ b/e2e/utils/test.ts @@ -14,59 +14,49 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; +import { test as baseTest } from '@playwright/test'; -import { expect, test as baseTest } from '@playwright/test'; +import { API_HEADER_KEY, API_PREFIX } from '@/config/constant'; -import { fileExists, getAPISIXConf } from './common'; -import { env } from './env'; +import { getAPISIXConf } from './common'; export type Test = typeof test; -export const test = baseTest.extend({ - storageState: ({ workerStorageState }, use) => use(workerStorageState), - workerStorageState: [ - async ({ browser }, use) => { - // Use parallelIndex as a unique identifier for each worker. - const id = test.info().parallelIndex; - const fileName = path.resolve( - test.info().project.outputDir, - `.auth/${id}.json` - ); - const { adminKey } = await getAPISIXConf(); - - // file exists and contains admin key, use it - if ( - (await fileExists(fileName)) && - (await readFile(fileName)).toString().includes(adminKey) - ) { - return use(fileName); - } - - const page = await browser.newPage({ storageState: undefined }); - // have to use env here, because the baseURL is not available in worker - await page.goto(env.E2E_TARGET_URL); - - // we need to authenticate - const settingsModal = page.getByRole('dialog', { name: 'Settings' }); - await expect(settingsModal).toBeVisible(); - // PasswordInput renders with a label, use getByLabel instead - const adminKeyInput = page.getByLabel('Admin Key'); - await adminKeyInput.clear(); - await adminKeyInput.fill(adminKey); - await page - .getByRole('dialog', { name: 'Settings' }) - .getByRole('button') - .click(); - - await page.context().storageState({ path: fileName }); - await page.close(); - await use(fileName); - }, - { scope: 'worker' }, - ], - page: async ({ baseURL, page }, use) => { +type TestOptions = { + /** + * Whether to authenticate Admin API requests by injecting the + * `X-API-KEY` header at the network layer. On by default. + * + * The dashboard holds the admin key in memory only (it is no longer + * persisted to `localStorage`), so auth cannot be replayed through + * Playwright's `storageState` the way it used to be. Instead we add the + * header to every Admin API request for the whole browser context, which + * keeps the app authenticated across full-page navigations and reloads + * (the e2e suite navigates with `page.goto`, i.e. fresh document loads) + * without re-driving the Settings modal on every page. + * + * Tests that exercise the manual auth flow itself (`auth.spec.ts`) opt + * out via `test.use({ injectAdminKey: false })`. + */ + injectAdminKey: boolean; +}; + +export const test = baseTest.extend({ + injectAdminKey: [true, { option: true }], + page: async ({ baseURL, page, injectAdminKey }, use) => { + if (injectAdminKey) { + const { adminKey } = await getAPISIXConf(); + await page.route( + (url) => url.pathname.startsWith(API_PREFIX), + (route) => + route.continue({ + headers: { + ...route.request().headers(), + [API_HEADER_KEY.toLowerCase()]: adminKey, + }, + }) + ); + } await page.goto(baseURL); await use(page); },