Skip to content

Commit

Permalink
fix(tls-metadata-headers): add intermediate certificates metadata (#1…
Browse files Browse the repository at this point in the history
…0024)

Include intermediate CA certificates and their details in `X-Forwarded-Client-Cert` header.

This change keeps the existent headers behavior and adds `X-Forwarded-Client-Cert`, following the description from [Envoy's docs](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers.html#x-forwarded-client-cert). Not all keys suggested by Envoy are added, but `Chain` which is the one we needed now is included.

FTI-6094
KAG-4951
Closes #9755

---------

Co-authored-by: Zhefeng Chen <[email protected]>
  • Loading branch information
locao and catbro666 authored Aug 26, 2024
1 parent b7fbb90 commit 0dff95f
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
message: "**tls-metadata-headers**: Fixed an issue where intermediate certificates details were not included in X-Forwarded-Client-Cert header."
type: bugfix
scope: Plugin
3 changes: 3 additions & 0 deletions kong/clustering/compat/removed_fields.lua
Original file line number Diff line number Diff line change
Expand Up @@ -458,5 +458,8 @@ return {
statsd_advanced = {
"queue.concurrency_limit",
},
tls_metadata_headers = {
"forwarded_client_cert_header_name",
},
},
}
93 changes: 76 additions & 17 deletions kong/plugins/tls-metadata-headers/handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@
local ngx = ngx
local kong = kong
local set_header = kong.service.request.set_header
local clear_header = kong.service.request.clear_header
local ngx_var = ngx.var
local meta = require "kong.meta"
local openssl_x509 = require "resty.openssl.x509"
local resty_kong_tls = require "resty.kong.tls"
local escape_uri = ngx.escape_uri
local ngx_re_match = ngx.re.match
local fmt = string.format
local to_hex = require "resty.string".to_hex


local TLSMetadataHandler = {
Expand All @@ -20,34 +27,86 @@ local TLSMetadataHandler = {
VERSION = meta.core_version
}


local function escape_fwcc_header_element_value(value)
if ngx_re_match(value, [=[["]]=]) then
value = value:gsub([["]], [[\"]])
end

if ngx_re_match(value, [=[[=,;]]=]) then
value = fmt([["%s"]], value)
end

return value
end


-- envoy implementation of the XFCC header:
-- https://github.com/envoyproxy/envoy/blob/8809f6bfe62e35c5bc42a1c6739167b71c64f637/source/common/http/conn_manager_utility.cc#L411-L504
-- https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers.html#x-forwarded-client-cert
function TLSMetadataHandler:access(conf)

if conf.inject_client_cert_details then
if not conf.inject_client_cert_details then
return
end

if ngx.var.ssl_client_escaped_cert then
-- add http headers
set_header(conf.client_cert_header_name,
ngx_var.ssl_client_escaped_cert)
local ssl_client_escaped_cert = ngx.var.ssl_client_escaped_cert
if ssl_client_escaped_cert then
-- add http headers
set_header(conf.client_cert_header_name, ssl_client_escaped_cert)

set_header(conf.client_serial_header_name,
ngx_var.ssl_client_serial)
set_header(conf.client_serial_header_name,
ngx_var.ssl_client_serial)

set_header(conf.client_cert_issuer_dn_header_name,
ngx_var.ssl_client_i_dn)
set_header(conf.client_cert_issuer_dn_header_name,
ngx_var.ssl_client_i_dn)

set_header(conf.client_cert_subject_dn_header_name,
ngx_var.ssl_client_s_dn)
set_header(conf.client_cert_subject_dn_header_name,
ngx_var.ssl_client_s_dn)

set_header(conf.client_cert_fingerprint_header_name,
ngx_var.ssl_client_fingerprint)
set_header(conf.client_cert_fingerprint_header_name,
ngx_var.ssl_client_fingerprint)

else
kong.log.err("plugin enabled to inject tls client certificate headers, but " ..
"no client certificate was provided")
end
else
-- if the connection is not mutual TLS, remove the XFCC header
clear_header(conf.forwarded_client_cert_header_name)
kong.log.err("plugin enabled to inject tls client certificate headers, but " ..
"no client certificate was provided")
return
end

-- TODO: `By` needs the current proxy's cert.
-- Should call `SSL_get_certificate` in `lua-kong-nginx-modulel` or `lua-resty-openssl` first
local fwcc_header_value = fmt("Cert=%s;Subject=%s",
escape_fwcc_header_element_value(ssl_client_escaped_cert),
escape_fwcc_header_element_value(ngx_var.ssl_client_s_dn))

local x509, err = openssl_x509.new(ngx_var.ssl_client_raw_cert, "PEM")
if x509 then
local cert_hash = to_hex(x509:digest("sha256"))
fwcc_header_value = fmt("%s;Hash=%s", fwcc_header_value, cert_hash)

else
kong.log.err("could not create a new x509 instance: ", err)
end

local full_chain = resty_kong_tls.get_full_client_certificate_chain()
if full_chain then
fwcc_header_value = fmt("%s;Chain=%s", fwcc_header_value,
escape_fwcc_header_element_value(escape_uri(full_chain)))
else
kong.log.err("could not get full client certificate chain")
end

local orig_fwcc_header_value = ngx.req.get_headers()[conf.forwarded_client_cert_header_name]
if orig_fwcc_header_value then
if type(orig_fwcc_header_value) == "string" then
fwcc_header_value = fmt("%s,%s", orig_fwcc_header_value, fwcc_header_value)
end
end

set_header(conf.forwarded_client_cert_header_name, fwcc_header_value)

end

return TLSMetadataHandler
6 changes: 6 additions & 0 deletions kong/plugins/tls-metadata-headers/schema.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ return {
description = "Define the HTTP header name used for the SHA1 fingerprint of the client certificate.",
default = "X-Client-Cert-Fingerprint"
}, },
{ forwarded_client_cert_header_name = {
type = "string",
required = true,
description = "Define the HTTP header name used for clients or proxies certificate information. If inject_client_cert_details is set to true, the plugin will add certificate details to this header. If set to false, the plugin will forward the certificate information as it was received.",
default = "X-Forwarded-Client-Cert"
}, },
},
}, },
},
Expand Down
19 changes: 19 additions & 0 deletions spec-ee/03-plugins/38-tls-metadata-headers/01-access_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ local tls_fixtures = { http_mock = {
proxy_pass https://127.0.0.1:9443/get;
}
location = /intermediate_client {
proxy_ssl_certificate ]] .. fixtures_path .. [[/intermediate_client_example.com.crt;
proxy_ssl_certificate_key ]] .. fixtures_path .. [[/intermediate_client_example.com.key;
proxy_ssl_name example.com;
proxy_set_header Host example.com;
proxy_pass https://127.0.0.1:9443/get;
}
}
]], }
}
Expand Down Expand Up @@ -139,6 +147,17 @@ for _, strategy in strategies() do
assert.is_nil(json.headers["X-Client-Cert"])
end)

it("returns HTTP 200 on https request if intermediate certificate passed", function()
local res = assert(tls_client:send {
method = "GET",
path = "/intermediate_client",
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
assert.is_nil(json.headers["X-Client-Cert"])
assert.is_nil(json.headers["X-Forwarded-Client-Cert"])
end)

end)


Expand Down
107 changes: 103 additions & 4 deletions spec-ee/03-plugins/38-tls-metadata-headers/02-integration_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
local helpers = require "spec.helpers"
local cjson = require "cjson"
local escape_uri = ngx.escape_uri
local openssl_x509 = require "resty.openssl.x509"
local to_hex = require "resty.string".to_hex

local strategies = helpers.all_strategies ~= nil and helpers.all_strategies or helpers.each_strategy

Expand All @@ -17,7 +19,7 @@ local fixture_path_from_prefix = "../" .. fixture_path

local function read_fixture(filename)
local content = assert(helpers.utils.readfile(fixture_path .. filename))
return content
return content
end


Expand Down Expand Up @@ -67,6 +69,22 @@ local tls_fixtures = { http_mock = {
proxy_pass https://127.0.0.1:9443/anything;
}
location = /intermediate_client {
proxy_ssl_certificate ]] .. fixture_path_from_prefix .. [[/intermediate_client_example.com.crt;
proxy_ssl_certificate_key ]] .. fixture_path_from_prefix .. [[/intermediate_client_example.com.key;
proxy_ssl_name example.com;
proxy_set_header Host example.com;
proxy_pass https://127.0.0.1:9443/get;
}
location = /good_client_multi-chain {
proxy_ssl_certificate ]] .. fixture_path_from_prefix .. [[/client_example.com.crt;
proxy_ssl_certificate_key ]] .. fixture_path_from_prefix .. [[/client_example.com.key;
proxy_ssl_name example.com;
proxy_set_header Host example.com;
proxy_pass https://127.0.0.1:9443/get;
}
}
]], }
}
Expand All @@ -75,7 +93,7 @@ for _, strategy in strategies() do
describe("Plugin: tls plugins (access) [#" .. strategy .. "]", function()
local proxy_ssl_client, tls_client
local bp, db
local ca_cert
local ca_cert, intermediate_cert
local service_https, route_https1, route_https2, route_https3
local db_strategy = strategy ~= "off" and strategy or nil

Expand Down Expand Up @@ -140,6 +158,10 @@ for _, strategy in strategies() do
cert = read_fixture("ca.crt"),
}))

intermediate_cert = assert(db.ca_certificates:insert({
cert = read_fixture("intermediate_ca.crt"),
}))

route_https3 = bp.routes:insert {
service = { id = service_https.id, },
hosts = { "example.com" },
Expand All @@ -150,8 +172,13 @@ for _, strategy in strategies() do
assert(bp.plugins:insert {
name = "mtls-auth",
route = { id = route_https3.id },
config = { skip_consumer_lookup = true,
ca_certificates = { ca_cert.id, }, },
config = { skip_consumer_lookup = true,
allow_partial_chain = true,
ca_certificates = {
ca_cert.id,
intermediate_cert.id,
},
},
})

assert(bp.plugins:insert {
Expand Down Expand Up @@ -226,6 +253,78 @@ for _, strategy in strategies() do
assert.equal("88b74971771571c618e6c6215ba4f6ef71ccc2c7", json.headers["x-client-cert-fingerprint-custom"])
end)

it("returns HTTP 200 on https request if intermediate certificate validation passed", function()
local res = assert(tls_client:send {
method = "GET",
path = "/intermediate_client",
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
local m = assert(ngx.re.match(read_fixture("intermediate_client_example.com.crt"),
[[^\X+(?<cert>-----BEGIN CERTIFICATE-----\X+-----END CERTIFICATE-----\X*)]]))

local cert = escape_uri(m["cert"])
assert.equal(cert, json.headers["x-client-cert"])
assert.equal("1001", json.headers["x-client-cert-serial"])
assert.equal("CN=Interm.", json.headers["x-client-cert-issuer-dn"])
assert.equal("CN=1.example.com", json.headers["x-client-cert-subject-dn"])
assert.equal("4cf374a3d5a4afc25b87b7bb315b4140dfc69165", json.headers["x-client-cert-fingerprint"])
end)

it("returns HTTP 200 on https request if multi-chain certificate validation passed", function()
local res = assert(tls_client:send {
method = "GET",
path = "/good_client_multi-chain",
})
local body = assert.res_status(200, res)
local json = cjson.decode(body)
local certs = read_fixture("client_example.com.crt")
local it = assert(ngx.re.gmatch(certs,
[[(-----[BEGIN \S\ ]+?-----[\S\s]+?-----[END \S\ ]+?-----\X)]]))
local client_cert = assert(it()[1])
local escaped_client_cert = escape_uri(client_cert)
local x509 = assert(openssl_x509.new(client_cert, "PEM"))

-- x-client-cert should contain a single certificate
assert.equal("string", type(json.headers["x-client-cert"]))

assert.equal(escaped_client_cert, json.headers["x-client-cert"])
assert.equal("a65e0ff498d954b0ac33fd4f35f6d02de145667b", json.headers["x-client-cert-fingerprint"])

local xfcc_element = json.headers["x-forwarded-client-cert"]

local expected_cert = escaped_client_cert
local expected_subject = "\"[email protected],O=Kong Testing,ST=California,C=US\""
local expected_hash = to_hex(x509:digest("sha256"))
local expected_chain = escape_uri(certs)
local expected_xfcc = string.format("Cert=%s;Subject=%s;Hash=%s;Chain=%s",
expected_cert, expected_subject, expected_hash, expected_chain)

local xfcc_cert = ngx.re.match(xfcc_element, [[Cert=([^;]+);]])
assert.equal(expected_cert, xfcc_cert[1])
local xfcc_subject = ngx.re.match(xfcc_element, [[Subject=("[^";]+");]])
assert.equal(expected_subject, xfcc_subject[1])
local xfcc_hash = ngx.re.match(xfcc_element, [[Hash=([^;]+);]])
assert.equal(expected_hash, xfcc_hash[1])
local xfcc_chain = ngx.re.match(xfcc_element, [[Chain=(\S+0A)]])
assert.equal(expected_chain, xfcc_chain[1])
assert.equal(expected_xfcc, xfcc_element)

-- should append the xfcc element correcly
local original_xfcc = "Subject=\"CN=test\";Hash=194a2e827dd41919e5385a8776ddc211326dd7fc78752c671e35001ba8ef1936"
res = assert(tls_client:send {
method = "GET",
path = "/good_client_multi-chain",
headers = {
["x-forwarded-client-cert"] = original_xfcc,
}
})
body = assert.res_status(200, res)
json = cjson.decode(body)
local xfcc = json.headers["x-forwarded-client-cert"]
assert.equal(original_xfcc .. "," .. xfcc_element, xfcc)
end)

end)

describe("no certificate", function()
Expand Down
17 changes: 17 additions & 0 deletions spec/fixtures/tls-metadata-headers/intermediate_ca.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICsjCCAZqgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UEAwwHUm9v
dC1jYTAgFw0yMjEwMTkxNDQzNDVaGA8yMTIyMDkyNTE0NDM0NVowEjEQMA4GA1UE
AwwHSW50ZXJtLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ6uSUl/
FBmIzbq+UD1HROTiJ+ftJa0KwgEg0JwsKbd+9Ne92MlNNzG9glO8eWlIRsZTlkz9
DxDXFJIMRqP7Fn9ZPeOAi2/VH+xIctBaIRcF/E/RwwrxnKOpaJvXOFudUg+YIPjP
H59Wof4PQMU9ijArc6KNRuVlMDQlC9MSaX9lhUzO4Nk8IT9rmLi0Z5O0KK+mFkWv
uN2uL9TqEumvea+Y5JKDitJxwFmGjGB18GIoKT0fOZVio/xyMuv7t7PybzE87wsd
bACkqO48pwwIMC/TCpeWaxZ1+sSoT3zZXdD+tua/MLIM2ubrmBZFJKYP8mxOqUgK
D29gWpcuZAIlrnUCAwEAAaMQMA4wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsF
AAOCAQEAVHSP6GPjLmvAuyOWncRKgBWJaP17UF0lZYIkJDW258nTqmQD2FMlNrp5
l/r/5pkl45BOsf3kxsqjZNx/1QuyLfeb6R7BIWMSzdFvNuzYjqyfQHADxTuq6cCA
3/eZ+fQA8da6LSLeIH+zKftNjDLjqAEVziID4ZQd1U2tHTMgFwNjlAH/ydAtqmdN
HkWpdejvtYnUSWQrcJZN/C/vFGukNly06LFRd71iTHyPWg+8nybJXFOMfrW6qfMi
SRAb/oQJaOMxXNrpXEQv/vbO8BK3LGmq2Bm2WIVFUhDKEdOqSvmeWoa8eM0bKT39
fs6geD+F2d4dQAUspVmBp1z6nlb/FA==
-----END CERTIFICATE-----
Loading

0 comments on commit 0dff95f

Please sign in to comment.