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
32 changes: 32 additions & 0 deletions system/backend/php/lib/HAXCMS.php
Original file line number Diff line number Diff line change
Expand Up @@ -2604,6 +2604,38 @@ public function appJWTConnectionSettings($base = '/')
};
$multisiteUrlName = $extractSiteNameFromPath($refererPath);
$sitename = $multisiteUrlName;
// D6: single-site mode resolution mirroring Node connectionSettings.js
// (179-196). In single-site mode the referer has no /_sites/<name>/
// segment so $sitename is blank. Resolve from the single-site manifest
// (HAXCMS_ROOT/site.json) so the siteToken mints for user:sitename
// instead of 'user:' (which wouldn't validate against the site API).
if ($sitename === '') {
$singleSiteJsonPath = HAXCMS_ROOT . '/site.json';
if (file_exists($singleSiteJsonPath)) {
$singleSiteJson = @file_get_contents($singleSiteJsonPath);
if ($singleSiteJson !== false) {
$singleSite = json_decode($singleSiteJson);
if (
$singleSite &&
isset($singleSite->metadata) &&
isset($singleSite->metadata->site) &&
isset($singleSite->metadata->site->name) &&
is_string($singleSite->metadata->site->name) &&
$singleSite->metadata->site->name !== ''
) {
$sitename = (string) $singleSite->metadata->site->name;
}
else if (
$singleSite &&
isset($singleSite->name) &&
is_string($singleSite->name) &&
$singleSite->name !== ''
) {
$sitename = (string) $singleSite->name;
}
}
}
}
$requestTokenUser = $this->getRequestTokenUserName();
// user token includes user and site name of the request
$siteToken = $this->getRequestToken($requestTokenUser . ':' . $sitename);
Expand Down
22 changes: 17 additions & 5 deletions system/backend/php/lib/routes/cloneSite.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ public function cloneSite() {
if (isset($this->params['user_token']) && $GLOBALS['HAXCMS']->validateRequestToken($this->params['user_token'], $GLOBALS['HAXCMS']->getActiveUserName())) {
$site = $GLOBALS['HAXCMS']->loadSite($this->params['site']['name']);
$siteDirectoryPath = $site->directory . '/' . $site->manifest->metadata->site->name;
$originalPathForReplacement = "/sites/" . $site->manifest->metadata->site->name . "/files/";
$originalSiteName = $site->manifest->metadata->site->name;
// F6: build the file-path rewrite prefix from the configured basePath +
// sitesDirectory instead of hardcoding /sites/<name>/files/ (mirror Node
// cloneSite.js:99-155). Keep the legacy /sites/ prefix as a fallback
// source so existing paths that use it are still rewritten correctly.
$basePath = isset($GLOBALS['HAXCMS']->basePath) ? rtrim((string) $GLOBALS['HAXCMS']->basePath, '/') : '';
$sitesDirectory = isset($GLOBALS['HAXCMS']->sitesDirectory) && $GLOBALS['HAXCMS']->sitesDirectory != ''
? $GLOBALS['HAXCMS']->sitesDirectory : '_sites';
$configuredSourcePrefix = $basePath . '/' . $sitesDirectory . '/' . $originalSiteName . '/files/';
$legacySourcePrefix = '/sites/' . $originalSiteName . '/files/';
$cloneName = $GLOBALS['HAXCMS']->getUniqueName($site->name);
// ensure the path to the new folder is valid
// resolve symlinks so that mirror copies real contents instead of recreating links
Expand All @@ -62,17 +71,20 @@ public function cloneSite() {
$site->manifest->metadata->site->name = $cloneName;
$site->manifest->id = $GLOBALS['HAXCMS']->generateUUID();
// loop through all items and rewrite the path to files as we cloned it
$targetPrefix = $basePath . '/' . $sitesDirectory . '/' . $cloneName . '/files/';
foreach ($site->manifest->items as $delta => $item) {
if (isset($item->metadata->files)) {
foreach ($item->metadata->files as $delta2 => $file) {
// F6: replace both the configured prefix and the legacy /sites/
// prefix with the configured target prefix.
$site->manifest->items[$delta]->metadata->files[$delta2]->path = str_replace(
$originalPathForReplacement,
'/sites/' . $cloneName . '/files/',
array($configuredSourcePrefix, $legacySourcePrefix),
$targetPrefix,
$site->manifest->items[$delta]->metadata->files[$delta2]->path
);
$site->manifest->items[$delta]->metadata->files[$delta2]->fullUrl = str_replace(
$originalPathForReplacement,
'/sites/' . $cloneName . '/files/',
array($configuredSourcePrefix, $legacySourcePrefix),
$targetPrefix,
$site->manifest->items[$delta]->metadata->files[$delta2]->fullUrl
);
}
Expand Down
8 changes: 6 additions & 2 deletions system/backend/php/lib/routes/listSites.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ public function listSites() {
if ($item != "." && $item != ".." && is_dir(HAXCMS_ROOT . '/' . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item) && file_exists(HAXCMS_ROOT . '/' . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/site.json')) {
$json = file_get_contents(HAXCMS_ROOT . '/' . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/site.json');
$site = json_decode($json);
if (isset($site->title)) {
// F6: don't filter by title (Node listSites.js includes all valid
// sites). Just verify the json_decode produced a valid site object.
if ($site && is_object($site)) {
$site->location = $GLOBALS['HAXCMS']->basePath . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/';
$site->slug = $GLOBALS['HAXCMS']->basePath . $GLOBALS['HAXCMS']->sitesDirectory . '/' . $item . '/';
$site->metadata->pageCount = count($site->items);
if (isset($site->metadata) && is_object($site->metadata)) {
$site->metadata->pageCount = isset($site->items) && is_array($site->items) ? count($site->items) : 0;
}
// we don't need all items stored here
unset($site->items);
$return['items'][] = $site;
Expand Down
40 changes: 28 additions & 12 deletions system/backend/php/lib/routes/login.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ private function processCredentialLogin($u, $p, $legacy = false) {
$entry = $this->registerFailedLoginAttempt($entry, $nowMs, $settings);
$this->saveLoginAttemptEntry($attemptKey, $entry, $settings);
}
// D2/Q8: login failure returns 401 (not 403) per spec; invalid-bearer
// on protected routes stays 403, but credential login failure is 401.
return array(
'__failed' => array(
'status' => 403,
'status' => 401,
'message' => 'Access denied',
)
);
Expand Down Expand Up @@ -167,26 +169,40 @@ public function login() {
if (isset($this->params['username']) && isset($this->params['password'])) {
return $this->processCredentialLogin($this->params['username'], $this->params['password'], false);
}
//old way
// if we don't have a user and the don't answer, bail
else if (isset($this->params['u']) && isset($this->params['p'])) {
return $this->processCredentialLogin($this->params['u'], $this->params['p'], true);
}
// login end point requested yet a jwt already exists
// this is something of a revalidate case
// D2/Q7: login end point requested yet a jwt already exists — this is a
// revalidate case. The body jwt was previously stripped in the session v1
// handler; it now reaches this branch so JWT-revalidate works in v1.
// D2/Q8: use validateJWT(false) so an invalid jwt returns a 401 envelope
// instead of exiting with 403. Set sessionJwt from the body jwt first so
// validateJWT can decode it (sessionJwt is normally set from the bearer
// header in the HAXCMS constructor).
else if (isset($this->params['jwt'])) {
Comment on lines 169 to 179
$bodyJwt = is_string($this->params['jwt']) ? trim($this->params['jwt']) : '';
if ($bodyJwt !== '') {
$GLOBALS['HAXCMS']->sessionJwt = $bodyJwt;
}
$valid = $GLOBALS['HAXCMS']->validateJWT(false);
if ($valid) {
return array(
"status" => 200,
"jwt" => $GLOBALS['HAXCMS']->getJWT($GLOBALS['HAXCMS']->getActiveUserName()),
);
}
return array(
"status" => 200,
"jwt" => $GLOBALS['HAXCMS']->validateJWT(),
'__failed' => array(
'status' => 401,
'message' => 'Invalid token',
)
);
}
else {
// D2/Q8: login required returns 401 (not 403) per spec.
return array(
'__failed' => array(
'status' => 403,
'status' => 401,
'message' => 'Login is required',
)
);
}
}
}
}
2 changes: 1 addition & 1 deletion system/backend/php/lib/routes/saveOutline.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function saveOutline() {
return array(
'__failed' => array(
'status' => 400,
'message' => 'Missing outline items payload',
'message' => 'Outline payload requires an items array',
)
);
}
Expand Down
17 changes: 16 additions & 1 deletion system/backend/php/lib/siteRoutes/SiteApiRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ class SiteApiRouter
{
public static function dispatch($site)
{
// C1: startup mutation-security guard mirroring Node
// assertSiteApiMutationRoutesAreSecured (app.js:1829). Runs once per
// process so a misconfigured spec (a mutation route resolving to
// public) is surfaced via error_log without blocking request handling.
static $mutationGuardChecked = false;
if (!$mutationGuardChecked) {
$mutationGuardChecked = true;
SiteRouteUtils::assertSiteApiMutationRoutesAreSecured(SiteRoutesMap::getRoutesMap());
}
$context = SiteApiRequestContext::fromSite($site);
if (!$context->isSiteApiRequest()) {
return false;
Expand Down Expand Up @@ -82,9 +91,13 @@ public static function dispatch($site)
);
return true;
}
// C1: pass the matched route PATTERN (e.g. v1/items/:idOrSlug) so the
// spec-driven auth policy reader can look up the security declaration
// by route key. Mirrors Node validateSiteApiRouteAccess(req, siteRoute)
// where siteRoute is the Express route pattern, not the concrete path.
$authResult = SiteApiSecurity::validateSiteApiAccess(
$context,
is_string($context->routeSuffix) ? $context->routeSuffix : '',
isset($match['route']) ? $match['route'] : '',
$context->method
);
if (!$authResult['allowed']) {
Expand Down Expand Up @@ -140,6 +153,7 @@ public static function matchRoute($routeSuffix, $routes = array())
return array(
'file' => $routeFile,
'params' => $matchedParams,
'route' => $pattern,
);
}
$fallbackMatch = self::matchSpecialCaseRoutes($targetRoute, $routes);
Expand Down Expand Up @@ -200,6 +214,7 @@ private static function matchSpecialCaseRoutes($targetRoute, $routes = array())
return array(
'file' => $routes[$case['route']],
'params' => $params,
'route' => $case['route'],
);
}
return null;
Expand Down
79 changes: 42 additions & 37 deletions system/backend/php/lib/siteRoutes/SiteApiSecurity.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,51 +110,56 @@ public static function validateSiteApiAccess($context, $routeSuffix = '', $metho
$result['message'] = '';
return $result;
}
if ($policy === 'authenticated-user') {
// C1: spec-driven user-token enforcement for site API routes that
// declare userTokenHeader. Mirrors Node validateSiteApiRouteAccess
// authenticated-user branch (app.js:2328-2354).
$userToken = null;
if (
isset($context) &&
is_object($context) &&
method_exists($context, 'getHeader')
) {
$userToken = $context->getHeader('X-HAXCMS-User-Token');
}
if (is_null($userToken) || $userToken === '') {
$result['status'] = 403;
$result['message'] = 'X-HAXCMS-User-Token header is required for this endpoint';
return $result;
}
$validUserToken = false;
if (
isset($GLOBALS['HAXCMS']) &&
is_object($GLOBALS['HAXCMS']) &&
method_exists($GLOBALS['HAXCMS'], 'validateRequestToken')
) {
$validUserToken = $GLOBALS['HAXCMS']->validateRequestToken($userToken, $userName);
}
if (!$validUserToken) {
$result['status'] = 403;
$result['message'] = 'Invalid X-HAXCMS-User-Token header';
return $result;
}
$result['allowed'] = true;
$result['status'] = 200;
$result['message'] = '';
return $result;
}
return $result;
}
private static function getRoutePolicy($routeSuffix, $method)
{
$suffix = trim((string) $routeSuffix, '/');
$upperMethod = strtoupper((string) $method);
if ($upperMethod === 'OPTIONS') {
return 'public';
}
if (in_array($upperMethod, array('POST', 'PATCH', 'PUT', 'DELETE'), true)) {
return 'authenticated-site';
}
$publicPatterns = array(
'/^$/',
'/^openapi(\\.json|\\.yaml)?$/',
'/^v1$/',
'/^v1\\/openapi(\\.json|\\.yaml)?$/',
'/^v1\\/site$/',
'/^v1\/items$/',
'/^v1\/items\/[^\/]+$/',
'/^v1\/content$/',
'/^v1\/content\/[^\/]+$/',
'/^v1\/tags$/',
'/^v1\/search$/',
'/^v1\/custom-elements/',
'/^v1\/blocks/',
'/^v1\/regions/',
'/^v1\/themes/',
'/^v1\/analytics$/',
'/^v1\/views/',
'/^v1\/displays/',
'/^v1\/entities$/',
'/^v1\/schemas$/',
'/^v1\/site\/export\/[^\/]+$/',
'/^v1\/items\/[^\/]+\/export\/[^\/]+$/',
);
foreach ($publicPatterns as $pattern) {
if (preg_match($pattern, $suffix)) {
return 'public';
}
}
if (preg_match('/^v1\/items\/[^\/]+\/revisions/', $suffix)) {
return 'authenticated-site';
}
return 'authenticated';
// C1/Q6: spec-driven auth policy. Read the security declaration from
// site-spec.yaml at runtime and fail-closed to 'authenticated' for any
// route not declared in the spec. Mirrors Node getSiteApiRouteAuthPolicy
// (app.js:1709). This resolves A1 structurally: files/reports GETs
// inherit bearer+siteToken from the spec (authenticated-site) instead of
// the old regex table which left them as bare 'authenticated'.
return SiteRouteUtils::getSiteApiRouteAuthPolicy($routeSuffix, $upperMethod);
}
private static function resolveBearerUserName()
{
Expand Down
Loading
Loading