Skip to content

Writing CORS origins caches the pre-write value, so a tightened CORS policy stays open for up to 2 minutes #12561

Description

@truecourse-agent

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    New

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions