The schema is cached per-origin and privately, not publicly for a year

INBUXA Admin, hosted off the mail server as SPEC.md §5.3 requires, signs in
and then cannot load: "Failed to load the admin panel configuration. Failed
to fetch." Every other endpoint works from the same origin with the same
token; only /api/schema fails, and it is the one thing a schema-driven
interface cannot do without.

It is Chrome's cache, not CORS. Measured from the page itself: a normal
fetch fails, while cache: "reload", cache: "no-store" and a cache-busted URL
all return 200. The server never sees the failing request, which is why the
logs had nothing to show and why it looked like a CORS fault for so long.

Two things made that possible, and both are fixed here.

The schema response was `public, max-age=31536000, immutable`. It is served
behind authenticate_headers and its CORS headers vary by Origin, so it is
neither public nor safe to freeze for a year on a hash-named URL that never
changes. It is now `private`, matching what DownloadResponse already does
for the same reason. The other caller of with_immutable_cache serves the
applications' static bundles, which really are public, and keeps it.

And `Vary: Origin` was only emitted when an origin list existed. Before the
front ends are configured that list is empty, so a response cached in that
window carries neither CORS headers nor Vary, and a cache will later replay
it to an origin that should have been allowed. Vary now goes on every
response, so entries key on the origin whatever the configuration was when
they were stored.

Verified against a bootstrapped server in restrictive CORS mode, from a
browser on a separate origin: /api/account, /api/schema and the hashed
target all return 200, with `private, max-age=31536000, immutable` and
`Vary: Origin`.

Nobody hit this before because the admin has always been served from the
mail host at /admin, where it is same-origin and no CORS applies. The first
deployment that follows §5.3 meets it immediately.
This commit is contained in:
2026-09-20 01:43:15 -07:00
parent 92d2b07c2e
commit 7c4add8425
3 changed files with 32 additions and 5 deletions
+16
View File
@@ -180,6 +180,22 @@ impl HttpResponse {
self
}
/// inbuxa: the same, for a response that required authentication.
///
/// `public` lets any cache keep the body and hand it to anyone, and
/// `immutable` means a browser will not revalidate it for a year. On a
/// response whose headers depend on the request's `Origin`, that is a trap:
/// an entry stored while the origin was not yet an allowed front end has no
/// CORS headers and no `Vary`, and is then replayed from cache to a caller
/// that would have been allowed, which fails as an opaque network error
/// with nothing on the server to show for it.
pub fn with_private_immutable_cache(mut self) -> Self {
self.builder = self
.builder
.header(header::CACHE_CONTROL, "private, max-age=31536000, immutable");
self
}
pub fn with_location<V>(mut self, location: V) -> Self
where
V: TryInto<HeaderValue>,
+5 -1
View File
@@ -117,9 +117,13 @@ impl ManagementApi for Server {
include_str!("../../../../resources/schema/schema.json.sha256");
if path.get(1).is_some_and(|hash| hash == &SCHEMA_HASH) {
// inbuxa: private, not public. This is behind
// authenticate_headers and its CORS headers vary by
// Origin, so a shared or origin-agnostic cache entry is
// wrong -- and, being immutable, wrong for a year.
Ok(Resource::new("application/json", SCHEMA_JSON.to_vec())
.into_http_response()
.with_immutable_cache()
.with_private_immutable_cache()
.with_header(CONTENT_ENCODING, "gzip"))
} else {
Ok(HttpResponse::redirect(format!("/api/schema/{SCHEMA_HASH}")))
+10 -3
View File
@@ -812,13 +812,20 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
// (contract C-14). Responses that already set their own
// CORS headers, such as public discovery metadata, keep
// them (C-15).
// inbuxa: Vary goes on every response, not only when an
// origin list exists. A response cached while the list was
// empty -- before the front ends were configured -- would
// otherwise carry neither CORS headers nor Vary, and a
// cache would replay it to an origin that should have been
// allowed. With `immutable` on some of these, that is a
// year of an opaque failure the server never sees.
let cors_origins = &server.core.network.http.cors_origins;
if !cors_origins.is_empty() {
let headers = response.headers_mut();
headers.append(
response.headers_mut().append(
hyper::header::VARY,
hyper::header::HeaderValue::from_static("Origin"),
);
if !cors_origins.is_empty() {
let headers = response.headers_mut();
if let Some(origin) = origin.filter(|origin| {
cors_origins.contains(origin)
&& !headers.contains_key(hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN)