fix(bootstrap): validate access_key server-side, prevent stale cache bypass
When a URL access_key is present, skip the Dexie cache fast-path in lookup_site_domain entirely — the key must be validated against the API. Previously, a stale cached entry with a previously-valid key would be returned immediately, allowing access even after the key changed or was revoked in the URL. Also: add site_domain_access_key to properties_to_save__site_domain so domain-level keys are persisted to Dexie for cache validation; remove shadow access_key re-declaration in +layout.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,10 +46,9 @@ When the frontend first loads and doesn't know the `account_id`, it performs a "
|
|||||||
|
|
||||||
> **Access Key Support**
|
> **Access Key Support**
|
||||||
>
|
>
|
||||||
> Some client deployments restrict their domain to authenticated sessions via an access key set in the URL (e.g. `https://client.example.com/?key=abc123`).
|
> Some client deployments restrict their domain via an access key passed in the browser URL (e.g. `?key=abc123`). The frontend reads this param and forwards it as `access_key` in the POST body.
|
||||||
>
|
>
|
||||||
> **How to pass the key:**
|
> **How to pass the key:**
|
||||||
> Read the `key` query param from the browser URL. If present and non-empty, include it as `access_key` in the POST body:
|
|
||||||
> ```json
|
> ```json
|
||||||
> {
|
> {
|
||||||
> "and": [
|
> "and": [
|
||||||
@@ -58,24 +57,23 @@ When the frontend first loads and doesn't know the `account_id`, it performs a "
|
|||||||
> ]
|
> ]
|
||||||
> }
|
> }
|
||||||
> ```
|
> ```
|
||||||
> If `key` is absent, empty, or falsy — omit `access_key` from the payload entirely (do not send `"access_key": ""`).
|
> If `key` is absent, empty, or falsy — **omit `access_key` from the payload entirely**. Do not send `"access_key": ""`.
|
||||||
>
|
>
|
||||||
> **Server behavior:**
|
> **Server behavior:**
|
||||||
> - `v_site_domain.site_access_key` (site-level) takes priority. If set, all domains under that site require it.
|
> - `site_access_key` (site-level key) takes priority. If set, all domains under that site require it.
|
||||||
> - `v_site_domain.site_domain_access_key` (domain-level) is used only when `site_access_key` is not set.
|
> - `site_domain_access_key` (domain-level key) is used as fallback when `site_access_key` is not set.
|
||||||
> - A domain is **public** only when **both** key columns are NULL/empty — no `access_key` needed.
|
> - A domain is **public** only when **both** key columns are NULL/empty.
|
||||||
> - Falsy `access_key` values (empty string, etc.) are ignored server-side as a safety net.
|
> - Falsy `access_key` values are ignored server-side as a safety net.
|
||||||
> - A successful match returns `200` with the matching record. No match returns `200` with an empty list `[]`.
|
> - Match → `200` with the record. No match → `200` with empty list `[]`.
|
||||||
> - Do **not** use `access_code_kv_json` for this — that field is consumed by UI features and is unrelated to access control.
|
> - Do **not** use `access_code_kv_json` for this — that field is for UI features only.
|
||||||
>
|
>
|
||||||
> **Examples:**
|
|
||||||
> | Browser URL | `access_key` in payload | Result |
|
> | Browser URL | `access_key` in payload | Result |
|
||||||
> |---|---|---|
|
> |---|---|---|
|
||||||
> | `https://dev-demo.oneskyit.com` | *(omit)* | ✅ Returns record (public domain) |
|
> | `https://dev-demo.oneskyit.com` | *(omit)* | ✅ Returns record (public) |
|
||||||
> | `https://client.example.com/?key=correct` | `"correct"` | ✅ Returns record |
|
> | `https://client.example.com/?key=correct` | `"correct"` | ✅ Returns record |
|
||||||
> | `https://client.example.com/` | *(omit)* | ❌ Empty list (key required) |
|
> | `https://client.example.com/` | *(omit)* | ❌ Empty (key required) |
|
||||||
> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty list (wrong key) |
|
> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty (wrong key) |
|
||||||
> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty list (key required) |
|
> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty (key required) |
|
||||||
>
|
>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -112,29 +112,33 @@ export async function lookup_site_domain({
|
|||||||
console.log(`*** lookup_site_domain() *** fqdn=${fqdn} (Cache-First)`);
|
console.log(`*** lookup_site_domain() *** fqdn=${fqdn} (Cache-First)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. FAST PATH: Check local cache first
|
// 1. FAST PATH: Check local cache first.
|
||||||
let cached = null;
|
// Skip when access_key is provided — the key must be validated server-side.
|
||||||
try {
|
// A stale cached entry with a previously-valid key must not grant access if the
|
||||||
cached = await db_core.site_domain.where('fqdn').equals(fqdn).first();
|
// URL key has changed or been revoked.
|
||||||
if (cached) {
|
if (!access_key) {
|
||||||
if (log_lvl)
|
try {
|
||||||
console.log(
|
const cached = await db_core.site_domain.where('fqdn').equals(fqdn).first();
|
||||||
'BOOTSTRAP: Cache hit. Returning cached site domain immediately.'
|
if (cached) {
|
||||||
);
|
if (log_lvl)
|
||||||
|
console.log(
|
||||||
|
'BOOTSTRAP: Cache hit. Returning cached site domain immediately.'
|
||||||
|
);
|
||||||
|
|
||||||
// Trigger background refresh to keep cache fresh, but don't await it
|
// Trigger background refresh to keep cache fresh, but don't await it
|
||||||
_refresh_site_domain_background({
|
_refresh_site_domain_background({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
fqdn,
|
fqdn,
|
||||||
view,
|
view,
|
||||||
log_lvl: 0,
|
log_lvl: 0,
|
||||||
access_key
|
access_key
|
||||||
});
|
});
|
||||||
|
|
||||||
return cached as any;
|
return cached as ae_SiteDomain;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('BOOTSTRAP: Cache read failed.', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn('BOOTSTRAP: Cache read failed.', err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SLOW PATH: Wait for API if cache is empty
|
// 2. SLOW PATH: Wait for API if cache is empty
|
||||||
@@ -730,6 +734,7 @@ const properties_to_save__site_domain = [
|
|||||||
'account_name',
|
'account_name',
|
||||||
'fqdn',
|
'fqdn',
|
||||||
'access_key',
|
'access_key',
|
||||||
|
'site_domain_access_key',
|
||||||
'enable',
|
'enable',
|
||||||
'enable_from',
|
'enable_from',
|
||||||
'enable_to',
|
'enable_to',
|
||||||
|
|||||||
@@ -139,9 +139,46 @@ export async function load({ fetch, params, parent, route, url }) {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
if (log_lvl)
|
if (log_lvl)
|
||||||
console.log(
|
console.log(
|
||||||
'ROOT LOAD: Found cached site domain. Unblocking layout.'
|
'ROOT LOAD: Found cached site domain. Evaluating cache vs URL key.'
|
||||||
);
|
);
|
||||||
result = cached;
|
|
||||||
|
// If the URL contains an access_key, we must ensure the cached
|
||||||
|
// entry is valid for that key. If the cache has no keys or the
|
||||||
|
// keys don't match, force a live lookup to revalidate.
|
||||||
|
if (access_key) {
|
||||||
|
const cachedSiteKey =
|
||||||
|
(cached as any).access_key ||
|
||||||
|
(cached as any).site_access_key ||
|
||||||
|
(cached as any).site_domain_access_key ||
|
||||||
|
'';
|
||||||
|
const cachedDomainKey =
|
||||||
|
(cached as any).site_domain_access_key ||
|
||||||
|
(cached as any).site_access_key ||
|
||||||
|
(cached as any).access_key ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (!cachedSiteKey && !cachedDomainKey) {
|
||||||
|
if (log_lvl)
|
||||||
|
console.log(
|
||||||
|
'BOOTSTRAP: Cache has no access keys; forcing live lookup because URL contains a key.'
|
||||||
|
);
|
||||||
|
// leave `result` null so slow-path lookup runs
|
||||||
|
} else if (
|
||||||
|
access_key === cachedSiteKey ||
|
||||||
|
access_key === cachedDomainKey
|
||||||
|
) {
|
||||||
|
if (log_lvl)
|
||||||
|
console.log('BOOTSTRAP: URL key matches cached access key; using cached object.');
|
||||||
|
result = cached as any;
|
||||||
|
} else {
|
||||||
|
if (log_lvl)
|
||||||
|
console.log('BOOTSTRAP: URL key does not match cached keys; forcing live lookup to revalidate.');
|
||||||
|
// leave `result` null so slow-path lookup runs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No access_key in URL — safe to use cached value
|
||||||
|
result = cached as any;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (log_lvl)
|
if (log_lvl)
|
||||||
@@ -370,8 +407,6 @@ export async function load({ fetch, params, parent, route, url }) {
|
|||||||
ae_loc_init['key_checked'] = true;
|
ae_loc_init['key_checked'] = true;
|
||||||
ae_loc_init['allow_access'] = true;
|
ae_loc_init['allow_access'] = true;
|
||||||
} else {
|
} else {
|
||||||
const access_key = url.searchParams.get('key');
|
|
||||||
|
|
||||||
if (access_key) {
|
if (access_key) {
|
||||||
if (log_lvl) {
|
if (log_lvl) {
|
||||||
console.log(`root +layout.ts: access_key = ${access_key}`);
|
console.log(`root +layout.ts: access_key = ${access_key}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user