Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 : {};
Expand Down
11 changes: 10 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/openapi/system-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ paths:
summary: Return configured API keys and provider statuses
security:
- bearerAuth: []
userTokenHeader: []
responses:
"200":
description: API key settings
Expand Down Expand Up @@ -837,6 +838,7 @@ paths:
summary: Return media and upload configuration
security:
- bearerAuth: []
userTokenHeader: []
responses:
"200":
description: Media settings
Expand Down
48 changes: 46 additions & 2 deletions test/api-conformance/actions-spec.conformance.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
9 changes: 9 additions & 0 deletions test/api-conformance/export-endpoints.integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading