From 489fc769cecd0fb0588291039cf26de1d4bd7b63 Mon Sep 17 00:00:00 2001 From: btopro Date: Wed, 5 Aug 2026 23:19:08 -0400 Subject: [PATCH 1/2] Enforce X-HAXCMS-User-Token on system READ operations (spec-driven) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add spec-driven userToken enforcement on system API reads that declare userTokenHeader in system-spec.yaml, mirroring the SITE API pattern (readSiteApiAuthPoliciesFromOpenApiSpec -> getSiteApiRouteAuthPolicy -> validateSiteApiRouteAccess). New system equivalents: readSystemApiAuthPoliciesFromOpenApiSpec(), getSystemApiRouteAuthPolicy(), enforceSystemApiUserTokenPolicy() — wired into both systemRouteHandler and siteScopedSystemRouteHandler after JWT/basic auth + admin-route referer gate succeed. Policy 'authenticated-user' routes enforce X-HAXCMS-User-Token: missing -> 403 'X-HAXCMS-User-Token header is required for this endpoint'; invalid -> 403 'Invalid X-HAXCMS-User-Token header' (matches existing SITE API code strings exactly for cross-repo parity). Uses the same validator as the SITE API (HAXCMS.validateRequestToken with bearer/basic-derived userName). system-spec.yaml: add userTokenHeader to getApiKeys + getMediaSettings GET reads so all 15 canonical userToken-requiring reads declare bearerAuth + userTokenHeader. Skeleton/theme/block reads stay bearer-only (D10). Writes unchanged (per-handler userToken enforcement kept). ITEM 3a: export-endpoints-php test asserts body.data.supportedFormats (D1 envelope) instead of body.supportedFormats. ITEM 3b: remove stale SITE integration-entity assertions from listEntityDescriptors subtest (D38 moved app-store provider-search to the system API). Verified systemEntities handler returns an integration entity descriptor (auth 'public', .../integrations/app-store) and added a system-side assertion in the new canonical-reads suite. New canonical-reads conformance block: asserts all 15 opIds declare bearerAuth + userTokenHeader, bearer-only -> 403, invalid userToken -> 403, valid userToken -> 200 for each read. Test helpers updated to send X-HAXCMS-User-Token for system writes/actions that declare userTokenHeader (createSite, action/import endpoints) whose handlers did not previously enforce userToken — the spec-driven gate now closes that spec/impl gap. Write handlers themselves unchanged. Validation: - npm run test:api-conformance -> 169 tests, 0 fail, 0 skip, EXIT=0 - npm run test:e2e -> 41 tests, 0 fail, 1 skip, EXIT=0 - node --check on all touched .js/.cjs files -> pass Co-Authored-By: Oz --- src/app.js | 117 ++++++++++ src/openapi/system-spec.yaml | 2 + .../actions-spec.conformance.test.cjs | 48 +++- .../export-endpoints-php.integration.test.cjs | 2 +- .../export-endpoints.integration.test.cjs | 9 + .../site-spec.conformance.test.cjs | 214 ++++++++++++++++-- .../api-conformance/ssrf.conformance.test.cjs | 42 +++- test/e2e/create-site.e2e.test.cjs | 6 +- test/e2e/helpers/harness.cjs | 41 ++++ 9 files changed, 453 insertions(+), 28 deletions(-) diff --git a/src/app.js b/src/app.js index 1ded1c36..17b15c44 100644 --- a/src/app.js +++ b/src/app.js @@ -204,7 +204,13 @@ const SITE_API_OPENAPI_SPEC_PATH = path.join( 'openapi', 'site-spec.yaml', ); +const SYSTEM_API_OPENAPI_SPEC_PATH = path.join( + __dirname, + 'openapi', + 'system-spec.yaml', +); let siteApiAuthPoliciesByMethodAndRoute = null; +let systemApiAuthPoliciesByMethodAndRoute = null; function getLinkedWebcomponentsRoot() { if (process.env.NODE_ENV !== "development") { @@ -1254,6 +1260,9 @@ systemStructureContext().then((site) => { isAuthenticated = basicAuth.authenticated; } if (isAuthenticated) { + if (!enforceSystemApiUserTokenPolicy(req, res, op, rMethod, basicAuth)) { + return; + } return systemRouteRegistry[rMethod][op](req, res, next); } // D1b status-code parity (matches site API + PHP SystemApiSecurity): @@ -1296,6 +1305,9 @@ systemStructureContext().then((site) => { isAuthenticated = basicAuth.authenticated; } if (isAuthenticated) { + if (!enforceSystemApiUserTokenPolicy(req, res, op, rMethod, basicAuth)) { + return; + } return systemRouteRegistry[rMethod][op](req, res, next); } // D1b status-code parity (matches site API + PHP SystemApiSecurity): @@ -1709,6 +1721,111 @@ function getSiteApiRouteAuthPolicy(route = '', method = 'get') { // spec requires authentication rather than falling open to public access. return 'authenticated'; } +function convertOpenApiPathToSystemRoute(openApiPath = '') { + let route = String(openApiPath || ''); + if (route.indexOf('/system/api/v1') !== 0) { + return ''; + } + route = route.replace(/^\/system\/api\/v1\/?/, ''); + route = route.replace(/^\//, ''); + route = route.replace(/\{([A-Za-z0-9_]+)\}/g, ':$1'); + return route; +} +function readSystemApiAuthPoliciesFromOpenApiSpec() { + const policies = {}; + if (!fs.existsSync(SYSTEM_API_OPENAPI_SPEC_PATH)) { + return policies; + } + try { + const openApiSpec = YAML.parse( + fs.readFileSync(SYSTEM_API_OPENAPI_SPEC_PATH, 'utf8'), + ); + if ( + !openApiSpec || + typeof openApiSpec !== 'object' || + !openApiSpec.paths || + typeof openApiSpec.paths !== 'object' + ) { + return policies; + } + const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; + const pathKeys = Object.keys(openApiSpec.paths); + for (let p = 0; p < pathKeys.length; p++) { + const openApiPath = pathKeys[p]; + if (String(openApiPath).indexOf('/system/api/v1') !== 0) { + continue; + } + const routeKey = convertOpenApiPathToSystemRoute(openApiPath); + const pathConfig = openApiSpec.paths[openApiPath]; + if (!pathConfig || typeof pathConfig !== 'object') { + continue; + } + const pathLevelPolicy = normalizeSiteApiSecurityPolicy(pathConfig.security); + for (let m = 0; m < methods.length; m++) { + const method = methods[m]; + if (!Object.prototype.hasOwnProperty.call(pathConfig, method)) { + continue; + } + const operation = pathConfig[method]; + if (!operation || typeof operation !== 'object') { + continue; + } + let policy = pathLevelPolicy; + if (Object.prototype.hasOwnProperty.call(operation, 'security')) { + policy = normalizeSiteApiSecurityPolicy(operation.security); + } + policies[`${method}:${routeKey}`] = policy; + } + } + } catch (e) { + console.warn('Unable to parse system OpenAPI auth policy map', e); + } + return policies; +} +function getSystemApiRouteAuthPolicy(route = '', method = 'get') { + if (!systemApiAuthPoliciesByMethodAndRoute) { + systemApiAuthPoliciesByMethodAndRoute = readSystemApiAuthPoliciesFromOpenApiSpec(); + } + const lookupKey = `${String(method || 'get').toLowerCase()}:${String(route || '')}`; + if ( + systemApiAuthPoliciesByMethodAndRoute && + Object.prototype.hasOwnProperty.call(systemApiAuthPoliciesByMethodAndRoute, lookupKey) + ) { + return systemApiAuthPoliciesByMethodAndRoute[lookupKey]; + } + // Fail closed: any system API route not explicitly declared in the OpenAPI + // spec requires authentication rather than falling open to public access. + return 'authenticated'; +} +function resolveSystemApiAuthenticatedUserName(req, basicAuth) { + if (basicAuth && basicAuth.authenticated && basicAuth.userName) { + return String(basicAuth.userName); + } + return getAuthenticatedUserNameFromBearerJwt(getBearerJwtFromRequest(req)); +} +function enforceSystemApiUserTokenPolicy(req, res, op, method, basicAuth) { + const policy = getSystemApiRouteAuthPolicy(op, method); + if (policy !== 'authenticated-user') { + return true; + } + const userToken = getRequestHeaderValue(req, 'x-haxcms-user-token'); + if (userToken === '') { + res.status(403).json({ + status: 403, + data: { message: 'X-HAXCMS-User-Token header is required for this endpoint' }, + }); + return false; + } + const userName = resolveSystemApiAuthenticatedUserName(req, basicAuth); + if (!HAXCMS.validateRequestToken(userToken, userName)) { + res.status(403).json({ + status: 403, + data: { message: 'Invalid X-HAXCMS-User-Token header' }, + }); + return false; + } + return true; +} function assertSiteApiMutationRoutesAreSecured(routeRegistry = null) { const registry = routeRegistry && typeof routeRegistry === 'object' ? routeRegistry : {}; diff --git a/src/openapi/system-spec.yaml b/src/openapi/system-spec.yaml index c6c43dc6..216adcff 100644 --- a/src/openapi/system-spec.yaml +++ b/src/openapi/system-spec.yaml @@ -775,6 +775,7 @@ paths: summary: Return configured API keys and provider statuses security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: API key settings @@ -837,6 +838,7 @@ paths: summary: Return media and upload configuration security: - bearerAuth: [] + userTokenHeader: [] responses: "200": description: Media settings diff --git a/test/api-conformance/actions-spec.conformance.test.cjs b/test/api-conformance/actions-spec.conformance.test.cjs index 8e57e5e3..6ccb90a1 100644 --- a/test/api-conformance/actions-spec.conformance.test.cjs +++ b/test/api-conformance/actions-spec.conformance.test.cjs @@ -7,6 +7,7 @@ const path = require('path') const os = require('os') const axios = require('axios') const JSZip = require('jszip') +const vm = require('node:vm') const REPO_ROOT = path.resolve(__dirname, '..', '..') const APP_ENTRY_PATH = path.join(REPO_ROOT, 'src', 'app.js') @@ -221,6 +222,37 @@ async function loginForJwt(baseUrl) { return loginBody.jwt } +function parseConnectionSettingsScript(scriptSource) { + const sandbox = { window: {} } + vm.runInNewContext(String(scriptSource || ''), sandbox, { timeout: 1000 }) + if ( + !sandbox.window || + !sandbox.window.appSettings || + typeof sandbox.window.appSettings !== 'object' + ) { + throw new Error( + 'Unable to parse appSettings from /system/api/v1/session/connection-settings response', + ) + } + return sandbox.window.appSettings +} + +async function requestConnectionSettings(baseUrl) { + const settingsResponse = await sendHttpRequest({ + method: 'GET', + url: `${baseUrl}/system/api/v1/session/connection-settings`, + headers: { + accept: 'application/javascript', + }, + }) + assert.equal( + settingsResponse.status, + 200, + `Expected connectionSettings success but received ${settingsResponse.status}: ${settingsResponse.bodyText}`, + ) + return parseConnectionSettingsScript(settingsResponse.bodyText) +} + async function setupRuntime() { const runtime = { originalCwd: process.cwd(), @@ -270,6 +302,10 @@ async function setupRuntime() { runtime.port = await runtime.appModule.serverReady runtime.baseUrl = `http://127.0.0.1:${runtime.port}` runtime.jwt = await loginForJwt(runtime.baseUrl) + runtime.dashboardSettings = await requestConnectionSettings(runtime.baseUrl) + runtime.userToken = runtime.dashboardSettings.userToken + runtime.userTokenHeader = + runtime.dashboardSettings.userTokenHeader || 'X-HAXCMS-User-Token' return runtime } @@ -312,20 +348,28 @@ async function teardownRuntime(runtime) { } function authHeaders(jwt, extraHeaders = {}) { - return { + const headers = { accept: 'application/json', 'content-type': 'application/json', Authorization: `Bearer ${jwt}`, ...extraHeaders, } + if (runtime && runtime.userToken) { + headers[runtime.userTokenHeader || 'X-HAXCMS-User-Token'] = runtime.userToken + } + return headers } function multipartAuthHeaders(jwt, boundary) { - return { + const headers = { accept: 'application/json', 'content-type': `multipart/form-data; boundary=${boundary}`, Authorization: `Bearer ${jwt}`, } + if (runtime && runtime.userToken) { + headers[runtime.userTokenHeader || 'X-HAXCMS-User-Token'] = runtime.userToken + } + return headers } let runtime = null diff --git a/test/api-conformance/export-endpoints-php.integration.test.cjs b/test/api-conformance/export-endpoints-php.integration.test.cjs index 7a1a23fe..e0022ed9 100644 --- a/test/api-conformance/export-endpoints-php.integration.test.cjs +++ b/test/api-conformance/export-endpoints-php.integration.test.cjs @@ -296,7 +296,7 @@ test('PHP unsupported item export format returns 400', async (t) => { }) assert.equal(result.status, 400, `unsupported format expected 400, got ${result.status}`) const body = parseJsonSafely(result.bodyText) - assert.ok(body && Array.isArray(body.supportedFormats), '400 response missing supportedFormats array') + assert.ok(body && body.data && Array.isArray(body.data.supportedFormats), '400 response missing supportedFormats array') }) test('PHP site-spec.yaml ItemExportFormat enum includes all 8 formats', async () => { diff --git a/test/api-conformance/export-endpoints.integration.test.cjs b/test/api-conformance/export-endpoints.integration.test.cjs index 0318b074..64ec364a 100644 --- a/test/api-conformance/export-endpoints.integration.test.cjs +++ b/test/api-conformance/export-endpoints.integration.test.cjs @@ -113,6 +113,15 @@ async function createHarnessSite(baseUrl, jwt, dashboardSettings, siteName) { const createSitePath = (dashboardSettings && typeof dashboardSettings.createSite === 'string' && dashboardSettings.createSite.trim() !== '') ? dashboardSettings.createSite : '/system/api/v1/sites' const createSiteHeaders = (dashboardSettings && dashboardSettings.createSiteHeaders && typeof dashboardSettings.createSiteHeaders === 'object') ? dashboardSettings.createSiteHeaders : {} const requestHeaders = { accept: 'application/json', 'content-type': 'application/json', Authorization: `Bearer ${jwt}`, ...createSiteHeaders } + if ( + dashboardSettings && + typeof dashboardSettings.userTokenHeader === 'string' && + dashboardSettings.userTokenHeader.trim() !== '' && + typeof dashboardSettings.userToken === 'string' && + dashboardSettings.userToken.trim() !== '' + ) { + requestHeaders[dashboardSettings.userTokenHeader] = dashboardSettings.userToken + } const normalizedCreateSitePath = String(createSitePath || '').trim() const createSiteUrl = /^https?:\/\//i.test(normalizedCreateSitePath) ? normalizedCreateSitePath : `${baseUrl}${normalizedCreateSitePath.charAt(0) === '/' ? '' : '/'}${normalizedCreateSitePath}` const createSiteResponse = await sendHttpRequest({ diff --git a/test/api-conformance/site-spec.conformance.test.cjs b/test/api-conformance/site-spec.conformance.test.cjs index 49978816..07e7611d 100644 --- a/test/api-conformance/site-spec.conformance.test.cjs +++ b/test/api-conformance/site-spec.conformance.test.cjs @@ -182,7 +182,6 @@ async function createHarnessSite(baseUrl, jwt, dashboardSettings, siteName) { ? normalizedCreateSitePath : `${baseUrl}${normalizedCreateSitePath.charAt(0) === '/' ? '' : '/'}${normalizedCreateSitePath}` if ( - (!requestHeaders || typeof requestHeaders !== 'object' || Object.keys(requestHeaders).length <= 2) && dashboardSettings && typeof dashboardSettings.userTokenHeader === 'string' && dashboardSettings.userTokenHeader.trim() !== '' && @@ -1503,28 +1502,10 @@ test('site API conformance against site-spec', async (t) => { Array.isArray(result.bodyJson.data.entities), 'Expected entity descriptors array', ) - const integrationDescriptor = result.bodyJson.data.entities.find( - (entity) => entity && entity.name === 'integration', - ) - assert.ok( - integrationDescriptor, - 'Expected integration entity descriptor to be present', - ) - assert.equal( - integrationDescriptor.auth, - 'authenticated-site', - 'Expected integration entity descriptor to require authenticated-site auth', - ) - assert.ok( - Array.isArray(integrationDescriptor.endpoints) && - integrationDescriptor.endpoints.some( - (endpoint) => - String(endpoint || '').indexOf( - '/v1/integrations/app-store/providers/{provider}/search', - ) !== -1, - ), - 'Expected integration descriptor to include app-store provider search endpoint', - ) + // D38: the integration entity descriptor was removed from the SITE API + // entities list because its app-store provider-search endpoint moved to + // the system API. The system entities endpoint (systemEntitiesGet) now + // owns the integration descriptor; see the system canonical-reads suite. assertSchemaConformance(runtime, 'listEntityDescriptors', 200, result) }) @@ -2625,3 +2606,190 @@ test('system API conformance for skeleton resource semantics', async (t) => { assertSystemSchemaConformance(runtime, 'systemSkeletonDetailGet', 404, deletedLookup) }) }) + +test('system API canonical user-token-secured reads enforce X-HAXCMS-User-Token', async (t) => { + const canonicalUserTokenReadOps = [ + 'listSites', + 'siteInfoGet', + 'siteInfoPost', + 'systemStatusGet', + 'systemStatusPost', + 'systemVersionGet', + 'systemVersionPost', + 'sessionUserGet', + 'sessionUserPost', + 'systemEntitiesGet', + 'systemEntitiesPost', + 'systemSchemasGet', + 'systemSchemasPost', + 'getApiKeys', + 'getMediaSettings', + ] + for (let i = 0; i < canonicalUserTokenReadOps.length; i++) { + const operationId = canonicalUserTokenReadOps[i] + const operationMeta = runtime.systemOperationIndex[operationId] + assert.ok( + operationMeta, + `Missing required operationId "${operationId}" in system-spec`, + ) + const security = Array.isArray(operationMeta.operation.security) + ? operationMeta.operation.security + : [] + assert.ok( + security.some( + (entry) => + entry && + typeof entry === 'object' && + Object.prototype.hasOwnProperty.call(entry, 'bearerAuth'), + ), + `${operationId} must declare bearerAuth security`, + ) + assert.ok( + security.some( + (entry) => + entry && + typeof entry === 'object' && + Object.prototype.hasOwnProperty.call(entry, 'userTokenHeader'), + ), + `${operationId} must declare userTokenHeader security (canonical user-token read)`, + ) + } + + async function assertSystemReadUserTokenEnforced(operationId, baseOptions = {}) { + const noCredsResult = await invokeSystemOperation( + runtime, + operationId, + mergeInvocationOptions(baseOptions, { headers: {} }), + ) + assert.equal( + noCredsResult.status, + 401, + `${operationId} should return 401 without auth headers`, + ) + assertSystemSchemaConformance(runtime, operationId, 401, noCredsResult) + + const bearerOnlyResult = await invokeSystemOperation( + runtime, + operationId, + mergeInvocationOptions(baseOptions, { + headers: getBearerAuthHeaders(runtime), + }), + ) + assert.equal( + bearerOnlyResult.status, + 403, + `${operationId} should return 403 with bearer token only (userToken required)`, + ) + assertSystemSchemaConformance(runtime, operationId, 403, bearerOnlyResult) + + const invalidUserTokenResult = await invokeSystemOperation( + runtime, + operationId, + mergeInvocationOptions(baseOptions, { + headers: getInvalidSystemUserAuthHeaders(runtime), + }), + ) + assert.equal( + invalidUserTokenResult.status, + 403, + `${operationId} should return 403 with invalid user token`, + ) + assertSystemSchemaConformance( + runtime, + operationId, + 403, + invalidUserTokenResult, + ) + + const validResult = await invokeSystemOperation( + runtime, + operationId, + mergeInvocationOptions(baseOptions, { + headers: getSystemUserAuthHeaders(runtime), + }), + ) + assert.equal( + validResult.status, + 200, + `${operationId} should return 200 with bearer + valid userToken`, + ) + assertSystemSchemaConformance(runtime, operationId, 200, validResult) + return validResult + } + + await t.test('listSites enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('listSites') + }) + await t.test('siteInfoGet enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('siteInfoGet', { + pathParams: { siteName: runtime.createdSiteName }, + }) + }) + await t.test('siteInfoPost enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('siteInfoPost', { + pathParams: { siteName: runtime.createdSiteName }, + }) + }) + await t.test('systemStatusGet enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemStatusGet') + }) + await t.test('systemStatusPost enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemStatusPost') + }) + await t.test('systemVersionGet enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemVersionGet') + }) + await t.test('systemVersionPost enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemVersionPost') + }) + await t.test('sessionUserGet enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('sessionUserGet') + }) + await t.test('sessionUserPost enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('sessionUserPost') + }) + await t.test('systemSchemasGet enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemSchemasGet') + }) + await t.test('systemSchemasPost enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemSchemasPost') + }) + await t.test('getApiKeys enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('getApiKeys') + }) + await t.test('getMediaSettings enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('getMediaSettings') + }) + await t.test('systemEntitiesGet returns integration entity descriptor (D38 system-side)', async () => { + const result = await assertSystemReadUserTokenEnforced('systemEntitiesGet') + assert.ok( + result.bodyJson && + result.bodyJson.data && + Array.isArray(result.bodyJson.data.entities), + 'Expected system entity descriptors array', + ) + const integrationDescriptor = result.bodyJson.data.entities.find( + (entity) => entity && entity.name === 'integration', + ) + assert.ok( + integrationDescriptor, + 'Expected integration entity descriptor on the system API', + ) + assert.equal( + integrationDescriptor.auth, + 'public', + 'Expected system integration entity descriptor to declare public auth', + ) + assert.ok( + Array.isArray(integrationDescriptor.endpoints) && + integrationDescriptor.endpoints.some( + (endpoint) => + String(endpoint || '').indexOf('/integrations/app-store') !== -1, + ), + 'Expected system integration descriptor to include the app-store endpoint', + ) + }) + await t.test('systemEntitiesPost enforces user token on read', async () => { + await assertSystemReadUserTokenEnforced('systemEntitiesPost') + }) +}) diff --git a/test/api-conformance/ssrf.conformance.test.cjs b/test/api-conformance/ssrf.conformance.test.cjs index 15a56621..4ef77770 100644 --- a/test/api-conformance/ssrf.conformance.test.cjs +++ b/test/api-conformance/ssrf.conformance.test.cjs @@ -6,6 +6,7 @@ const fs = require('fs-extra') const path = require('path') const os = require('os') const axios = require('axios') +const vm = require('node:vm') const REPO_ROOT = path.resolve(__dirname, '..', '..') const APP_ENTRY_PATH = path.join(REPO_ROOT, 'src', 'app.js') @@ -109,6 +110,37 @@ async function loginForJwt(baseUrl) { return loginBody.jwt } +function parseConnectionSettingsScript(scriptSource) { + const sandbox = { window: {} } + vm.runInNewContext(String(scriptSource || ''), sandbox, { timeout: 1000 }) + if ( + !sandbox.window || + !sandbox.window.appSettings || + typeof sandbox.window.appSettings !== 'object' + ) { + throw new Error( + 'Unable to parse appSettings from /system/api/v1/session/connection-settings response', + ) + } + return sandbox.window.appSettings +} + +async function requestConnectionSettings(baseUrl) { + const settingsResponse = await sendHttpRequest({ + method: 'GET', + url: `${baseUrl}/system/api/v1/session/connection-settings`, + headers: { + accept: 'application/javascript', + }, + }) + assert.equal( + settingsResponse.status, + 200, + `Expected connectionSettings success but received ${settingsResponse.status}: ${settingsResponse.bodyText}`, + ) + return parseConnectionSettingsScript(settingsResponse.bodyText) +} + async function setupRuntime() { const runtime = { originalCwd: process.cwd(), @@ -161,6 +193,10 @@ async function setupRuntime() { runtime.port = await runtime.appModule.serverReady runtime.baseUrl = `http://127.0.0.1:${runtime.port}` runtime.jwt = await loginForJwt(runtime.baseUrl) + runtime.dashboardSettings = await requestConnectionSettings(runtime.baseUrl) + runtime.userToken = runtime.dashboardSettings.userToken + runtime.userTokenHeader = + runtime.dashboardSettings.userTokenHeader || 'X-HAXCMS-User-Token' return runtime } @@ -207,12 +243,16 @@ async function teardownRuntime(runtime) { } function authHeaders(jwt, extraHeaders = {}) { - return { + const headers = { accept: 'application/json', 'content-type': 'application/json', Authorization: `Bearer ${jwt}`, ...extraHeaders, } + if (runtime && runtime.userToken) { + headers[runtime.userTokenHeader || 'X-HAXCMS-User-Token'] = runtime.userToken + } + return headers } // Create a plain site (no siteFiles) and return the machine name actually used diff --git a/test/e2e/create-site.e2e.test.cjs b/test/e2e/create-site.e2e.test.cjs index 7cd7e2a5..f8716def 100644 --- a/test/e2e/create-site.e2e.test.cjs +++ b/test/e2e/create-site.e2e.test.cjs @@ -525,10 +525,14 @@ test('create site (HAXSITEAUTOMATEDTESTING) — full E2E flow', async () => { console.warn('[e2e] fs check: cannot read _sites/: ' + e.message) } try { + const listHeaders = { Authorization: 'Bearer ' + runtime.jwt } + if (runtime.userToken) { + listHeaders[runtime.userTokenHeader || 'X-HAXCMS-User-Token'] = runtime.userToken + } const listResp = await axios({ method: 'GET', url: runtime.baseUrl + '/system/api/v1/sites', - headers: { Authorization: 'Bearer ' + runtime.jwt }, + headers: listHeaders, validateStatus: () => true, responseType: 'text', transformResponse: [(d) => d], diff --git a/test/e2e/helpers/harness.cjs b/test/e2e/helpers/harness.cjs index 59e984e4..4b386735 100644 --- a/test/e2e/helpers/harness.cjs +++ b/test/e2e/helpers/harness.cjs @@ -13,6 +13,7 @@ const fs = require('fs-extra') const path = require('path') const os = require('os') const axios = require('axios') +const vm = require('node:vm') const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') const APP_ENTRY_PATH = path.join(REPO_ROOT, 'src', 'app.js') @@ -111,6 +112,38 @@ async function loginForJwt(baseUrl, username, password) { return body.jwt } +function parseConnectionSettingsScript(scriptSource) { + const sandbox = { window: {} } + vm.runInNewContext(String(scriptSource || ''), sandbox, { timeout: 1000 }) + if ( + !sandbox.window || + !sandbox.window.appSettings || + typeof sandbox.window.appSettings !== 'object' + ) { + throw new Error( + 'Unable to parse appSettings from /system/api/v1/session/connection-settings response', + ) + } + return sandbox.window.appSettings +} + +async function requestConnectionSettings(baseUrl) { + const response = await axios({ + method: 'GET', + url: `${baseUrl}/system/api/v1/session/connection-settings`, + headers: { accept: 'application/javascript' }, + validateStatus: () => true, + responseType: 'text', + transformResponse: [(data) => data], + }) + if (response.status !== 200) { + throw new Error( + `E2E connectionSettings failed: status ${response.status}, body: ${response.data}`, + ) + } + return parseConnectionSettingsScript(response.data) +} + // Boot an isolated E2E runtime. Auth is ENABLED (HAXCMS_DISABLE_JWT_CHECKS is // explicitly deleted) so the dashboard login flow is exercised for real. async function setupE2ERuntime() { @@ -187,6 +220,14 @@ async function setupE2ERuntime() { E2E_USER_NAME, E2E_USER_PASSWORD, ) + // Fetch connection settings so direct API calls in tests can attach the + // X-HAXCMS-User-Token required by userTokenHeader-declaring system reads + // (e.g. listSites). The dashboard registry auto-attaches this for browser + // fetches, but direct axios calls in tests must add it explicitly. + runtime.dashboardSettings = await requestConnectionSettings(runtime.baseUrl) + runtime.userToken = runtime.dashboardSettings.userToken + runtime.userTokenHeader = + runtime.dashboardSettings.userTokenHeader || 'X-HAXCMS-User-Token' return runtime } From 5b78e161fb0ff7f914a119078fb6fbb3cc7b36e7 Mon Sep 17 00:00:00 2001 From: btopro Date: Thu, 6 Aug 2026 01:08:08 -0400 Subject: [PATCH 2/2] https://github.com/haxtheweb/issues/issues/2918 Wave 5 --- src/cli.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli.js b/src/cli.js index 37cf6321..01969c6d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -179,7 +179,16 @@ export async function cliBridge(op, body = {}, method = 'post', file = null) { } } if (siteName !== '') { - req.haxcmsSiteApiAuth = { siteName: siteName }; + // Mirror the CLI branch of validateSiteApiRouteAccess (src/app.js) so + // defense-in-depth site-mutation gates (isSiteApiRequestAuthenticated) + // and anonymous-visibility reads see the CLI as authenticated, not + // anonymous. cliBridge bypasses the HTTP route gate, so this context + // must be set here explicitly. + req.haxcmsSiteApiAuth = { + siteName: siteName, + authenticated: true, + securityLevel: 'authenticated-site', + }; } }