From e6daf6b503dcf19871c5c824a6b1a0d815e360ab Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 31 Mar 2026 15:07:41 -0400 Subject: [PATCH] fix(bootstrap): validate access_key server-side, prevent stale cache bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../GUIDE__AE_API_V3_for_Frontend.md | 26 +++++------ src/lib/ae_core/ae_core__site.ts | 45 ++++++++++--------- src/routes/+layout.ts | 43 ++++++++++++++++-- 3 files changed, 76 insertions(+), 38 deletions(-) diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index a7861b5e..b7e32d29 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -46,10 +46,9 @@ When the frontend first loads and doesn't know the `account_id`, it performs a " > **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:** -> Read the `key` query param from the browser URL. If present and non-empty, include it as `access_key` in the POST body: > ```json > { > "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:** -> - `v_site_domain.site_access_key` (site-level) 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. -> - A domain is **public** only when **both** key columns are NULL/empty — no `access_key` needed. -> - Falsy `access_key` values (empty string, etc.) 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 `[]`. -> - Do **not** use `access_code_kv_json` for this — that field is consumed by UI features and is unrelated to access control. +> - `site_access_key` (site-level key) takes priority. If set, all domains under that site require it. +> - `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. +> - Falsy `access_key` values are ignored server-side as a safety net. +> - Match → `200` with the record. No match → `200` with empty list `[]`. +> - Do **not** use `access_code_kv_json` for this — that field is for UI features only. > -> **Examples:** > | 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/` | *(omit)* | ❌ Empty list (key required) | -> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty list (wrong key) | -> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty list (key required) | +> | `https://client.example.com/` | *(omit)* | ❌ Empty (key required) | +> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty (wrong key) | +> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty (key required) | > --- diff --git a/src/lib/ae_core/ae_core__site.ts b/src/lib/ae_core/ae_core__site.ts index 7f93f282..ba9f67e0 100644 --- a/src/lib/ae_core/ae_core__site.ts +++ b/src/lib/ae_core/ae_core__site.ts @@ -112,29 +112,33 @@ export async function lookup_site_domain({ console.log(`*** lookup_site_domain() *** fqdn=${fqdn} (Cache-First)`); } - // 1. FAST PATH: Check local cache first - let cached = null; - try { - cached = await db_core.site_domain.where('fqdn').equals(fqdn).first(); - if (cached) { - if (log_lvl) - console.log( - 'BOOTSTRAP: Cache hit. Returning cached site domain immediately.' - ); + // 1. FAST PATH: Check local cache first. + // Skip when access_key is provided — the key must be validated server-side. + // A stale cached entry with a previously-valid key must not grant access if the + // URL key has changed or been revoked. + if (!access_key) { + try { + const cached = await db_core.site_domain.where('fqdn').equals(fqdn).first(); + 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 - _refresh_site_domain_background({ - api_cfg, - fqdn, - view, - log_lvl: 0, - access_key - }); + // Trigger background refresh to keep cache fresh, but don't await it + _refresh_site_domain_background({ + api_cfg, + fqdn, + view, + log_lvl: 0, + 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 @@ -730,6 +734,7 @@ const properties_to_save__site_domain = [ 'account_name', 'fqdn', 'access_key', + 'site_domain_access_key', 'enable', 'enable_from', 'enable_to', diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 0e8a1495..85446662 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -139,9 +139,46 @@ export async function load({ fetch, params, parent, route, url }) { if (cached) { if (log_lvl) 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) { 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['allow_access'] = true; } else { - const access_key = url.searchParams.get('key'); - if (access_key) { if (log_lvl) { console.log(`root +layout.ts: access_key = ${access_key}`);