From 83204eb5f6993dffe09a96cd5ad2fb3db66200d0 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Fri, 12 Jun 2026 09:13:48 +0800 Subject: [PATCH] fix(standalone): resolve env var references in config pushed via Admin API In standalone mode, the file-based config path resolves ${{VAR}} / $ENV:// references in config_yaml.parse(), but config pushed through the Admin API (`PUT /apisix/admin/configs`) is JSON-decoded straight into _update_config and bypassed that step, so the references were kept literal. Resolve them in the Admin API update path before applying the config, so both standalone config sources behave consistently. Adds a test that pushes a route whose proxy-rewrite uri uses a ${{VAR:=default}} reference and verifies the gateway resolves it. Signed-off-by: AlinsRan --- apisix/admin/standalone.lua | 13 ++++++ t/admin/standalone.spec.ts | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 46f9a8001bbf..c2ae52a4dd5f 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -28,6 +28,7 @@ local events = require("apisix.events") local core = require("apisix.core") local config_yaml = require("apisix.core.config_yaml") local config_validate = require("apisix.admin.config_validate") +local file = require("apisix.cli.file") local ALL_RESOURCE_KEYS = config_validate.get_all_resource_keys() @@ -128,6 +129,18 @@ local function update(ctx) return core.response.exit(204) end + -- resolve ${{VAR}} / $ENV:// references before validation, so that the + -- schema check and the stored config both see the resolved values. The + -- file-based standalone path resolves these in config_yaml.parse(); the + -- Admin API path decodes JSON straight into the config and would otherwise + -- validate and store the literal "${{...}}" string. + local resolved, resolve_err = file.resolve_conf_var(req_body) + if not resolved then + return core.response.exit(400, { + error_msg = "failed to resolve variables in config: " .. resolve_err + }) + end + local valid, error_msg = validate_configuration(req_body, false) if not valid then return core.response.exit(400, { error_msg = error_msg }) diff --git a/t/admin/standalone.spec.ts b/t/admin/standalone.spec.ts index 5a71c4122a91..54af5d240d05 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -944,4 +944,96 @@ describe('Validate API - Standalone', () => { }); }); }); + + describe('Variable resolution', () => { + it('resolves ${{VAR}} references in config pushed via the Admin API', async () => { + mockDigest += 1; + const config = { + routes: [ + { + id: 'r_var', + uri: '/r_var', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + // The proxy-rewrite uri uses a ${{VAR:=default}} reference. The + // gateway must resolve it to the default ("/hello"); if it were + // left literal, the upstream would receive "${{...}}" instead of + // "/hello" and would not return the hello body. + plugins: { + 'proxy-rewrite': { uri: '${{STANDALONE_ENV_TEST:=/hello}}' }, + }, + }, + ], + }; + const putResp = await client.put(ENDPOINT, config, { + headers: { [HEADER_DIGEST]: mockDigest }, + }); + expect(putResp.status).toEqual(202); + + const resp = await client.get('/r_var'); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual('hello world\n'); + }); + + it('coerces a resolved ${{VAR}} to its native type before validation', async () => { + mockDigest += 1; + const config = { + routes: [ + { + id: 'r_var_typed', + uri: '/r_var_typed', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + // retries is an integer field. The reference resolves to the + // default "2", which resolve_conf_var coerces to the number 2; + // a literal string "2" would fail integer schema validation + // with 400, so a 202 proves the value was coerced. + retries: '${{STANDALONE_ENV_RETRIES:=2}}', + }, + plugins: { + 'proxy-rewrite': { uri: '/hello' }, + }, + }, + ], + }; + const putResp = await client.put(ENDPOINT, config, { + headers: { [HEADER_DIGEST]: mockDigest }, + }); + expect(putResp.status).toEqual(202); + + const resp = await client.get('/r_var_typed'); + expect(resp.status).toEqual(200); + expect(resp.data).toEqual('hello world\n'); + }); + + it('rejects config with an unresolvable ${{VAR}} reference', async () => { + mockDigest += 1; + const config = { + routes: [ + { + id: 'r_var_bad', + uri: '/r_var_bad', + upstream: { + nodes: { '127.0.0.1:1980': 1 }, + type: 'roundrobin', + }, + // No matching environment variable and no ":=default", so + // resolution fails and the push is rejected with 400 rather than + // storing a literal "${{...}}". + plugins: { + 'proxy-rewrite': { uri: '${{STANDALONE_ENV_UNDEFINED}}' }, + }, + }, + ], + }; + const resp = await client + .put(ENDPOINT, config, { headers: { [HEADER_DIGEST]: mockDigest } }) + .catch((err) => err.response); + expect(resp.status).toEqual(400); + expect(resp.data.error_msg).toContain('failed to resolve variables in config'); + }); + }); });