From 69013d6e5b4bc78fffde03930b196cc9db1cd4ee Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Wed, 26 Aug 2026 16:04:24 +0800 Subject: [PATCH 1/3] fix(basic-auth): reject an empty consumer password The consumer schema accepted `password: ""`, and such a consumer could authenticate with `user: ` (or `user:` once credentials are split on the first colon). Require a non-empty password in the schema and fail closed with 401 when the resolved password is empty, which also covers existing data and secret references that resolve to "". Fixes #13881 --- apisix/plugins/basic-auth.lua | 16 ++- docs/en/latest/plugins/basic-auth.md | 2 +- docs/zh/latest/plugins/basic-auth.md | 2 +- t/admin/plugins.t | 4 +- t/plugin/basic-auth.t | 152 ++++++++++++++++++++++++++- 5 files changed, 169 insertions(+), 7 deletions(-) diff --git a/apisix/plugins/basic-auth.lua b/apisix/plugins/basic-auth.lua index 5d3f46a94201..a2238a339731 100644 --- a/apisix/plugins/basic-auth.lua +++ b/apisix/plugins/basic-auth.lua @@ -43,7 +43,7 @@ local consumer_schema = { title = "work with consumer object", properties = { username = { type = "string" }, - password = { type = "string" }, + password = { type = "string", minLength = 1 }, }, encrypt_fields = {"password"}, required = {"username", "password"}, @@ -148,7 +148,19 @@ local function find_consumer(ctx) return nil, nil, "Invalid user authorization" end - if cur_consumer.auth_conf.password ~= password then + -- fail closed: a consumer written before the schema required a non-empty + -- password, or a secret reference that resolves to "", must never authenticate + local expected = cur_consumer.auth_conf.password + if expected == "" then + err = "empty password configured for consumer: " .. cur_consumer.consumer_name + if auth_utils.is_running_under_multi_auth(ctx) then + return nil, nil, err + end + core.log.warn(err) + return nil, nil, "Invalid user authorization" + end + + if expected ~= password then return nil, nil, "Invalid user authorization" end diff --git a/docs/en/latest/plugins/basic-auth.md b/docs/en/latest/plugins/basic-auth.md index a50e99f20256..1e5056f8a83f 100644 --- a/docs/en/latest/plugins/basic-auth.md +++ b/docs/en/latest/plugins/basic-auth.md @@ -48,7 +48,7 @@ For Consumer/Credentials: | Name | Type | Required | Default | Valid values | Description | |------|------|----------|---------|--------------|-------------| | username | string | True | | | Unique basic auth username for a Consumer. | -| password | string | True | | | Basic auth password for the Consumer. The password is encrypted with AES before being stored in etcd. You can also store it in an environment variable and reference it using the `env://` prefix, or in a secret manager such as HashiCorp Vault's KV secrets engine, and reference it using the `secret://` prefix. | +| password | string | True | | non-empty string | Basic auth password for the Consumer. The password is encrypted with AES before being stored in etcd. You can also store it in an environment variable and reference it using the `env://` prefix, or in a secret manager such as HashiCorp Vault's KV secrets engine, and reference it using the `secret://` prefix. | For Route: diff --git a/docs/zh/latest/plugins/basic-auth.md b/docs/zh/latest/plugins/basic-auth.md index d554b55e2270..cc499d926018 100644 --- a/docs/zh/latest/plugins/basic-auth.md +++ b/docs/zh/latest/plugins/basic-auth.md @@ -48,7 +48,7 @@ import TabItem from '@theme/TabItem'; | 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | |------|------|--------|--------|--------|------| | username | string | 是 | | | 消费者的唯一基本认证用户名。 | -| password | string | 是 | | | 消费者的基本认证密码。密码在存储到 etcd 之前会使用 AES 加密。你也可以将其存储在环境变量中并使用 `env://` 前缀引用,或存储在 HashiCorp Vault 等密钥管理器中并使用 `secret://` 前缀引用。 | +| password | string | 是 | | 非空字符串 | 消费者的基本认证密码。密码在存储到 etcd 之前会使用 AES 加密。你也可以将其存储在环境变量中并使用 `env://` 前缀引用,或存储在 HashiCorp Vault 等密钥管理器中并使用 `secret://` 前缀引用。 | Route 端: diff --git a/t/admin/plugins.t b/t/admin/plugins.t index ef70e46d9175..b5d96031e0d2 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -264,7 +264,7 @@ plugins: ngx.HTTP_GET, nil, [[ -{"title":"work with consumer object","required":["username","password"],"properties":{"username":{"type":"string"},"password":{"type":"string"}},"type":"object"} +{"title":"work with consumer object","required":["username","password"],"properties":{"username":{"type":"string"},"password":{"type":"string","minLength":1}},"type":"object"} ]] ) @@ -371,7 +371,7 @@ qr/\[\{"name":"multi-auth","priority":2600\},\{"name":"wolf-rbac","priority":255 } } --- response_body eval -qr/\{"encrypt_fields":\["password"\],"properties":\{"password":\{"type":"string"\},"username":\{"type":"string"\}\},"required":\["username","password"\],"title":"work with consumer object","type":"object"\}/ +qr/\{"encrypt_fields":\["password"\],"properties":\{"password":\{"minLength":1,"type":"string"\},"username":\{"type":"string"\}\},"required":\["username","password"\],"title":"work with consumer object","type":"object"\}/ diff --git a/t/plugin/basic-auth.t b/t/plugin/basic-auth.t index 2e19a2f6d7f1..ab4383b38819 100644 --- a/t/plugin/basic-auth.t +++ b/t/plugin/basic-auth.t @@ -344,7 +344,7 @@ GET /t ngx.HTTP_GET, nil, [[ -{"title":"work with consumer object","required":["username","password"],"properties":{"username":{"type":"string"},"password":{"type":"string"}},"type":"object"} +{"title":"work with consumer object","required":["username","password"],"properties":{"username":{"type":"string"},"password":{"type":"string","minLength":1}},"type":"object"} ]] ) ngx.status = code @@ -708,3 +708,153 @@ Authorization: bASiC Zm9vOmJhcg== hello world --- error_log find consumer foo + + + +=== TEST 31: reject an empty password on the consumer +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "foo", + "plugins": { + "basic-auth": { + "username": "foo", + "password": "" + } + } + }]] + ) + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid plugins configuration: failed to check the configuration of plugin basic-auth err: property \"password\" validation failed: string too short, expected at least 1, got 0"} + + + +=== TEST 32: reject an empty password on the credential +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers/foo/credentials/cred_a', + ngx.HTTP_PUT, + [[{ + "plugins": { + "basic-auth": { + "username": "bar", + "password": "" + } + } + }]] + ) + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid plugins configuration: failed to check the configuration of plugin basic-auth err: property \"password\" validation failed: string too short, expected at least 1, got 0"} + + + +=== TEST 33: store an empty secret into vault +--- exec +VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/empty passwd= +--- response_body +Success! Data written to: kv/apisix/empty + + + +=== TEST 34: set basic-auth conf: password uses a secret ref that resolves to an empty string +--- request +GET /t +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/secrets/vault/test1', + ngx.HTTP_PUT, + [[{ + "uri": "http://127.0.0.1:8200", + "prefix" : "kv/apisix", + "token" : "root" + }]] + ) + if code >= 300 then + ngx.status = code + return ngx.say(body) + end + + code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "foo", + "plugins": { + "basic-auth": { + "username": "foo", + "password": "$secret://vault/test1/empty/passwd" + } + } + }]] + ) + if code >= 300 then + ngx.status = code + return ngx.say(body) + end + + code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "basic-auth": {} + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 35: verify, empty password on the wire (foo:) is rejected +--- request +GET /hello +--- more_headers +Authorization: Basic Zm9vOg== +--- error_code: 401 + + + +=== TEST 36: verify, whitespace-only password on the wire (foo: ) is rejected +--- request +GET /hello +--- more_headers +Authorization: Basic Zm9vOiA= +--- error_code: 401 +--- response_body +{"message":"Invalid user authorization"} +--- error_log +empty password configured for consumer: foo From b471cb2131a13100041bf92691ce84eb4352c78e Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Thu, 27 Aug 2026 12:08:34 +0800 Subject: [PATCH 2/3] fix(basic-auth): rely on the schema alone to reject an empty password The consumer schema is enforced on write by the Admin API and on load by plugin_checker, so the runtime empty-password check is redundant. Drop it together with the secret-reference tests that exercised it. --- apisix/plugins/basic-auth.lua | 14 +----- t/plugin/basic-auth.t | 93 ----------------------------------- 2 files changed, 1 insertion(+), 106 deletions(-) diff --git a/apisix/plugins/basic-auth.lua b/apisix/plugins/basic-auth.lua index a2238a339731..927271f65449 100644 --- a/apisix/plugins/basic-auth.lua +++ b/apisix/plugins/basic-auth.lua @@ -148,19 +148,7 @@ local function find_consumer(ctx) return nil, nil, "Invalid user authorization" end - -- fail closed: a consumer written before the schema required a non-empty - -- password, or a secret reference that resolves to "", must never authenticate - local expected = cur_consumer.auth_conf.password - if expected == "" then - err = "empty password configured for consumer: " .. cur_consumer.consumer_name - if auth_utils.is_running_under_multi_auth(ctx) then - return nil, nil, err - end - core.log.warn(err) - return nil, nil, "Invalid user authorization" - end - - if expected ~= password then + if cur_consumer.auth_conf.password ~= password then return nil, nil, "Invalid user authorization" end diff --git a/t/plugin/basic-auth.t b/t/plugin/basic-auth.t index ab4383b38819..304b4ff4646e 100644 --- a/t/plugin/basic-auth.t +++ b/t/plugin/basic-auth.t @@ -765,96 +765,3 @@ GET /t --- error_code: 400 --- response_body {"error_msg":"invalid plugins configuration: failed to check the configuration of plugin basic-auth err: property \"password\" validation failed: string too short, expected at least 1, got 0"} - - - -=== TEST 33: store an empty secret into vault ---- exec -VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/empty passwd= ---- response_body -Success! Data written to: kv/apisix/empty - - - -=== TEST 34: set basic-auth conf: password uses a secret ref that resolves to an empty string ---- request -GET /t ---- config - location /t { - content_by_lua_block { - local t = require("lib.test_admin").test - local code, body = t('/apisix/admin/secrets/vault/test1', - ngx.HTTP_PUT, - [[{ - "uri": "http://127.0.0.1:8200", - "prefix" : "kv/apisix", - "token" : "root" - }]] - ) - if code >= 300 then - ngx.status = code - return ngx.say(body) - end - - code, body = t('/apisix/admin/consumers', - ngx.HTTP_PUT, - [[{ - "username": "foo", - "plugins": { - "basic-auth": { - "username": "foo", - "password": "$secret://vault/test1/empty/passwd" - } - } - }]] - ) - if code >= 300 then - ngx.status = code - return ngx.say(body) - end - - code, body = t('/apisix/admin/routes/1', - ngx.HTTP_PUT, - [[{ - "plugins": { - "basic-auth": {} - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello" - }]] - ) - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- response_body -passed - - - -=== TEST 35: verify, empty password on the wire (foo:) is rejected ---- request -GET /hello ---- more_headers -Authorization: Basic Zm9vOg== ---- error_code: 401 - - - -=== TEST 36: verify, whitespace-only password on the wire (foo: ) is rejected ---- request -GET /hello ---- more_headers -Authorization: Basic Zm9vOiA= ---- error_code: 401 ---- response_body -{"message":"Invalid user authorization"} ---- error_log -empty password configured for consumer: foo From 7c71988bb17c96e11192e3b53986a3957a5052a3 Mon Sep 17 00:00:00 2001 From: janiussyafiq Date: Thu, 27 Aug 2026 15:01:35 +0800 Subject: [PATCH 3/3] fix(basic-auth): reject a password that resolves to an empty secret Restore the runtime check dropped in b471cb21: minLength only validates the $secret:// or $env:// reference string, and consumer.lua resolves it after validation, so a reference that resolves to "" would otherwise authenticate `user:`. Add regressions for both $secret:// and $env:// resolving to "". --- apisix/plugins/basic-auth.lua | 15 +++- t/plugin/basic-auth.t | 151 ++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/basic-auth.lua b/apisix/plugins/basic-auth.lua index 927271f65449..ba22c0ab7714 100644 --- a/apisix/plugins/basic-auth.lua +++ b/apisix/plugins/basic-auth.lua @@ -148,7 +148,20 @@ local function find_consumer(ctx) return nil, nil, "Invalid user authorization" end - if cur_consumer.auth_conf.password ~= password then + -- the schema rejects an empty password on write and on load, but a secret + -- reference ($secret:// or $env://) is resolved after validation and can + -- yield "": fail closed so such a consumer never authenticates + local expected = cur_consumer.auth_conf.password + if expected == "" then + err = "empty password configured for consumer: " .. cur_consumer.consumer_name + if auth_utils.is_running_under_multi_auth(ctx) then + return nil, nil, err + end + core.log.warn(err) + return nil, nil, "Invalid user authorization" + end + + if expected ~= password then return nil, nil, "Invalid user authorization" end diff --git a/t/plugin/basic-auth.t b/t/plugin/basic-auth.t index 304b4ff4646e..8a141859e971 100644 --- a/t/plugin/basic-auth.t +++ b/t/plugin/basic-auth.t @@ -765,3 +765,154 @@ GET /t --- error_code: 400 --- response_body {"error_msg":"invalid plugins configuration: failed to check the configuration of plugin basic-auth err: property \"password\" validation failed: string too short, expected at least 1, got 0"} + + + +=== TEST 33: store an empty secret into vault +--- exec +VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/empty passwd= +--- response_body +Success! Data written to: kv/apisix/empty + + + +=== TEST 34: set basic-auth conf: password uses a secret ref that resolves to an empty string +--- request +GET /t +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/secrets/vault/test1', + ngx.HTTP_PUT, + [[{ + "uri": "http://127.0.0.1:8200", + "prefix" : "kv/apisix", + "token" : "root" + }]] + ) + if code >= 300 then + ngx.status = code + return ngx.say(body) + end + + code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "foo", + "plugins": { + "basic-auth": { + "username": "foo", + "password": "$secret://vault/test1/empty/passwd" + } + } + }]] + ) + if code >= 300 then + ngx.status = code + return ngx.say(body) + end + + code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "basic-auth": {} + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 35: verify, empty password on the wire (foo:) is rejected +--- request +GET /hello +--- more_headers +Authorization: Basic Zm9vOg== +--- error_code: 401 + + + +=== TEST 36: verify, whitespace-only password on the wire (foo: ) is rejected +--- request +GET /hello +--- more_headers +Authorization: Basic Zm9vOiA= +--- error_code: 401 +--- response_body +{"message":"Invalid user authorization"} +--- error_log +empty password configured for consumer: foo + + + +=== TEST 37: set basic-auth conf: password uses an env ref that resolves to an empty string +--- main_config +env BASIC_AUTH_EMPTY_PASSWORD=; +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "foo", + "plugins": { + "basic-auth": { + "username": "foo", + "password": "$env://BASIC_AUTH_EMPTY_PASSWORD" + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 38: verify, empty password on the wire (foo:) is rejected +--- main_config +env BASIC_AUTH_EMPTY_PASSWORD=; +--- request +GET /hello +--- more_headers +Authorization: Basic Zm9vOg== +--- error_code: 401 + + + +=== TEST 39: verify, whitespace-only password on the wire (foo: ) is rejected +--- main_config +env BASIC_AUTH_EMPTY_PASSWORD=; +--- request +GET /hello +--- more_headers +Authorization: Basic Zm9vOiA= +--- error_code: 401 +--- response_body +{"message":"Invalid user authorization"} +--- error_log +empty password configured for consumer: foo