Summary
POST /api/admin/ui-config/cors writes the new origins to the database, and in the same call stamps the pre-write value into the in-memory cache the CORS middleware reads. Until the 2-minute fetchFrontendSettings scheduler tick (or an incidental GET /api/admin/ui-config) refreshes it, the Frontend API keeps applying the origins from before the write.
The security-relevant direction: after an operator narrows the allowed origins, the server keeps answering Access-Control-Allow-Origin: * to origins nobody listed.
Tested on main at 3d91a51635859f13a5bc23547d323c19718629fb (unleash-server 8.1.0), OSS, stock config, Postgres 15.
Reproduction
POST /api/admin/ui-config/cors
body: {"frontendApiOrigins":["https://tcref-app.example"]}
-> 204 (empty body)
GET /api/frontend?appName=tcref Origin: https://not-listed.example
-> HTTP/1.1 200 OK
Vary: Origin, Accept-Encoding
Access-Control-Allow-Origin: * <-- origins were just restricted to one
# the write did land in the database:
select * from settings where name = 'unleash.frontend';
-> {"frontendApiOrigins":["https://tcref-app.example"]}
And the mirror case, verbatim from a scripted run:
POST /api/admin/ui-config/cors
body: {"frontendApiOrigins":["*"]}
-> 204 (empty body)
GET /api/frontend?appName=tcref
-> HTTP/1.1 200 OK
{"toggles":[]}
(no Access-Control-Allow-Origin header) <-- "*" was just written
Control — an intervening GET /api/admin/ui-config (which the Admin UI issues constantly, and which refreshes the same cache) makes both cases behave correctly, which is why this is invisible with a browser tab open:
GET /api/admin/ui-config -> frontendApiOrigins = ["https://tcref-app.example"]
GET /api/frontend Origin: https://tcref-app.example
-> Access-Control-Allow-Origin: https://tcref-app.example
GET /api/frontend Origin: https://not-listed.example
-> (no ACAO header — correctly refused)
Control — left completely alone, with nothing touching /api/admin/ui-config, the value heals on its own at the scheduler tick, ~1 min 48 s after the write:
19:13:05 ACAO=<absent>
...
19:14:00 ACAO=<absent>
19:14:06 ACAO=Access-Control-Allow-Origin: *
This is not "the cache just isn't updated" — the write actively poisons it
The two hypotheses ("the write forgets to update the cache" vs "the write overwrites the cache with the pre-write database value") are distinguishable by changing the database behind the server's back first. Fresh instance:
cache primed to [a]; DB = {"frontendApiOrigins":["https://a.example"]}
Origin a -> Access-Control-Allow-Origin: https://a.example
Origin b -> (refused)
# change the DB directly to [b]; the server is unaware, cache still [a]
Origin a -> Access-Control-Allow-Origin: https://a.example (still [a])
POST /api/admin/ui-config/cors {"frontendApiOrigins":["https://c.example"]} -> 204
DB now = {"frontendApiOrigins":["https://c.example"]}
Origin a -> (refused)
Origin b -> Access-Control-Allow-Origin: https://b.example <-- !
Origin c -> (refused)
After writing [c], the live CORS policy is [b] — a value that was never cached and was not what was written. Only the pre-write read inside the setter can put it there.
Mechanism
src/lib/features/frontend-api/frontend-api-service.ts:243-258:
async setFrontendCorsSettings(
value: FrontendSettings['frontendApiOrigins'],
auditUser: IAuditUser,
): Promise<void> {
const error = validateOrigins(value);
if (error) {
throw new BadDataError(error);
}
const settings = (await this.getFrontendSettings(false)) || {}; // <-- line 251
await this.services.settingService.insert(
frontendSettingsKey,
{ ...settings, frontendApiOrigins: value },
auditUser,
false,
);
}
Line 251 passes useCache = false, which at :275-280 falls through to fetchFrontendSettings() — whose first statement is an assignment to the cache (:260-273):
async fetchFrontendSettings(): Promise<FrontendSettings> {
try {
this.cachedFrontendSettings =
await this.services.settingService.getWithDefault(
frontendSettingsKey,
{ frontendApiOrigins: this.config.frontendApiOrigins },
);
} catch (error) { ... }
return this.cachedFrontendSettings;
}
So the read-modify-write, whose intent is only to preserve sibling keys of the settings blob, has the side effect of caching the pre-write value. insert then updates the database and nothing else.
The middleware reads that cache on every Frontend API request — src/lib/middleware/cors-origin-middleware.ts:23-32, with useCache defaulted to true:
const corsFunc = cors(async (_req, callback) => {
const { frontendApiOrigins = [] } =
await frontendApiService.getFrontendSettings();
callback(null, { origin: resolveOrigin(frontendApiOrigins), ... });
and the only scheduled refresh is src/lib/features/scheduler/schedule-services.ts:154-159, every 2 minutes.
git log -S 'await this.getFrontendSettings(false)) || {}' --all -- src/ points at dc4a760, "feat: read logs and update cors maintenance root-role permissions (#8996)" (2025-01-08), which introduced the merge. Before that, the setter (#2694, 2022) wrote the value with no pre-write read.
Suggested fix
Get the existing settings without the caching side effect (read the setting directly rather than through getFrontendSettings), or assign this.cachedFrontendSettings from the value that was just written after insert succeeds.
Related docs
https://docs.getunleash.io/support/troubleshooting tells operators to set the origin to * to test and then restrict it again: "For troubleshooting: You can temporarily set the allowed origin to * (a single asterisk) to allow all origins. This helps confirm if CORS is the root cause." and "Important Security Note: Using * in production is generally discouraged. Always restrict origins to only those that require access." That is exactly the sequence that hits this: the tightening write does not take effect for up to two minutes, and curl-verifying the header (the next section of the same page) reports the old policy.
Suggested labels: bug
Summary
POST /api/admin/ui-config/corswrites the new origins to the database, and in the same call stamps the pre-write value into the in-memory cache the CORS middleware reads. Until the 2-minutefetchFrontendSettingsscheduler tick (or an incidentalGET /api/admin/ui-config) refreshes it, the Frontend API keeps applying the origins from before the write.The security-relevant direction: after an operator narrows the allowed origins, the server keeps answering
Access-Control-Allow-Origin: *to origins nobody listed.Tested on
mainat3d91a51635859f13a5bc23547d323c19718629fb(unleash-server 8.1.0), OSS, stock config, Postgres 15.Reproduction
And the mirror case, verbatim from a scripted run:
Control — an intervening
GET /api/admin/ui-config(which the Admin UI issues constantly, and which refreshes the same cache) makes both cases behave correctly, which is why this is invisible with a browser tab open:Control — left completely alone, with nothing touching
/api/admin/ui-config, the value heals on its own at the scheduler tick, ~1 min 48 s after the write:This is not "the cache just isn't updated" — the write actively poisons it
The two hypotheses ("the write forgets to update the cache" vs "the write overwrites the cache with the pre-write database value") are distinguishable by changing the database behind the server's back first. Fresh instance:
After writing
[c], the live CORS policy is[b]— a value that was never cached and was not what was written. Only the pre-write read inside the setter can put it there.Mechanism
src/lib/features/frontend-api/frontend-api-service.ts:243-258:Line 251 passes
useCache = false, which at:275-280falls through tofetchFrontendSettings()— whose first statement is an assignment to the cache (:260-273):So the read-modify-write, whose intent is only to preserve sibling keys of the settings blob, has the side effect of caching the pre-write value.
insertthen updates the database and nothing else.The middleware reads that cache on every Frontend API request —
src/lib/middleware/cors-origin-middleware.ts:23-32, withuseCachedefaulted totrue:and the only scheduled refresh is
src/lib/features/scheduler/schedule-services.ts:154-159, every 2 minutes.git log -S 'await this.getFrontendSettings(false)) || {}' --all -- src/points at dc4a760, "feat: read logs and update cors maintenance root-role permissions (#8996)" (2025-01-08), which introduced the merge. Before that, the setter (#2694, 2022) wrote the value with no pre-write read.Suggested fix
Get the existing settings without the caching side effect (read the setting directly rather than through
getFrontendSettings), or assignthis.cachedFrontendSettingsfrom the value that was just written afterinsertsucceeds.Related docs
https://docs.getunleash.io/support/troubleshooting tells operators to set the origin to
*to test and then restrict it again: "For troubleshooting: You can temporarily set the allowed origin to*(a single asterisk) to allow all origins. This helps confirm if CORS is the root cause." and "Important Security Note: Using*in production is generally discouraged. Always restrict origins to only those that require access." That is exactly the sequence that hits this: the tightening write does not take effect for up to two minutes, andcurl-verifying the header (the next section of the same page) reports the old policy.Suggested labels:
bug