From 83204eb5f6993dffe09a96cd5ad2fb3db66200d0 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Fri, 12 Jun 2026 09:13:48 +0800 Subject: [PATCH 1/2] 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'); + }); + }); }); From 7bef81e72c9cddacf0f75491f3e662545f6b8e67 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Thu, 29 Oct 2026 08:30:13 +0800 Subject: [PATCH 2/2] fix(standalone): resolve YAML bodies in the raw text, like the config file A YAML body is now substituted before it is parsed, so that quoting decides the type of a resolved value as it does in apisix.yaml; a JSON body is still resolved after decoding. The resolution moves to the parse step, which is where the file path does it. --- apisix/admin/standalone.lua | 35 +++++++++++++++++++++-------------- t/admin/standalone.spec.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apisix/admin/standalone.lua b/apisix/admin/standalone.lua index 62be499d7a13..ce746bd11c93 100644 --- a/apisix/admin/standalone.lua +++ b/apisix/admin/standalone.lua @@ -276,8 +276,18 @@ local function update(ctx) end -- parse the request body - local data - if core.string.has_prefix(content_type, "application/yaml") then + -- ${{VAR}} references are resolved the way the config file resolves them: + -- in the raw text for YAML, so that quoting decides the type, and after + -- decoding for JSON, whose types are explicit. + local is_yaml = core.string.has_prefix(content_type, "application/yaml") + local data, resolve_err + if is_yaml then + req_body, resolve_err = file.resolve_conf_var_in_text(req_body) + if not req_body then + return core.response.exit(400, { + error_msg = "failed to resolve variables in config: " .. resolve_err + }) + end local ok, result = pcall(yaml.load, req_body, { all = false }) -- a null document (`~`) loads as lyaml's sentinel table, which would -- otherwise pass as an empty object and clear every resource @@ -294,6 +304,15 @@ local function update(ctx) core.log.error("invalid request body, err: ", err) return core.response.exit(400, {error_msg = "invalid request body: " .. err}) end + if not is_yaml and type(data) == "table" then + local ok + ok, resolve_err = file.resolve_conf_var(data) + if not ok then + return core.response.exit(400, { + error_msg = "failed to resolve variables in config: " .. resolve_err + }) + end + end req_body = data local config, err = get_config() @@ -311,18 +330,6 @@ 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 54af5d240d05..173f9573dadd 100644 --- a/t/admin/standalone.spec.ts +++ b/t/admin/standalone.spec.ts @@ -1009,6 +1009,43 @@ describe('Validate API - Standalone', () => { expect(resp.data).toEqual('hello world\n'); }); + it('resolves ${{VAR}} in a YAML body before parsing, so quoting decides the type', async () => { + mockDigest += 1; + // An unquoted reference becomes the integer retries requires, while a + // quoted one stays a string even though its value looks like a number. + const body = [ + 'routes:', + ' - id: r_var_yaml', + ' uri: /r_var_yaml', + ' desc: "${{STANDALONE_ENV_DESC:=123}}"', + ' upstream:', + ' nodes:', + ' "127.0.0.1:1980": 1', + ' type: roundrobin', + ' retries: ${{STANDALONE_ENV_RETRIES:=2}}', + ' plugins:', + ' proxy-rewrite:', + ' uri: ${{STANDALONE_ENV_TEST:=/hello}}', + '', + ].join('\n'); + const putResp = await client.put(ENDPOINT, body, { + headers: { + 'Content-Type': 'application/yaml', + [HEADER_DIGEST]: mockDigest, + }, + }); + expect(putResp.status).toEqual(202); + + const dump = await client.get(ENDPOINT); + const route = dump.data.routes.find((r) => r.id === 'r_var_yaml'); + expect(route.desc).toEqual('123'); + expect(route.upstream.retries).toEqual(2); + + const resp = await client.get('/r_var_yaml'); + 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 = {