From 2bfd2dfc6123583be12191b64d2e18fd0eaa2ba8 Mon Sep 17 00:00:00 2001 From: keremoge Date: Fri, 2 Oct 2026 15:40:03 +0300 Subject: [PATCH 1/8] feat(plugin): add XML and SOAP request signing plugins --- Makefile | 9 + apisix/cli/config.lua | 2 + apisix/plugins/signing/algorithms.lua | 55 ++++ apisix/plugins/signing/credentials.lua | 100 ++++++ apisix/plugins/signing/request.lua | 88 +++++ apisix/plugins/signing/xml.lua | 358 ++++++++++++++++++++ apisix/plugins/soap-signer.lua | 156 +++++++++ apisix/plugins/soap-signer/signer.lua | 337 +++++++++++++++++++ apisix/plugins/xml-signer.lua | 124 +++++++ apisix/plugins/xml-signer/signer.lua | 141 ++++++++ conf/config.yaml.example | 2 + docs/en/latest/config.json | 2 + docs/en/latest/plugins/soap-signer.md | 359 ++++++++++++++++++++ docs/en/latest/plugins/xml-signer.md | 336 +++++++++++++++++++ docs/zh/latest/config.json | 2 + docs/zh/latest/plugins/soap-signer.md | 270 +++++++++++++++ docs/zh/latest/plugins/xml-signer.md | 248 ++++++++++++++ t/plugin/soap-signer.t | 355 ++++++++++++++++++++ t/plugin/xml-signer.t | 433 +++++++++++++++++++++++++ 19 files changed, 3377 insertions(+) create mode 100644 apisix/plugins/signing/algorithms.lua create mode 100644 apisix/plugins/signing/credentials.lua create mode 100644 apisix/plugins/signing/request.lua create mode 100644 apisix/plugins/signing/xml.lua create mode 100644 apisix/plugins/soap-signer.lua create mode 100644 apisix/plugins/soap-signer/signer.lua create mode 100644 apisix/plugins/xml-signer.lua create mode 100644 apisix/plugins/xml-signer/signer.lua create mode 100644 docs/en/latest/plugins/soap-signer.md create mode 100644 docs/en/latest/plugins/xml-signer.md create mode 100644 docs/zh/latest/plugins/soap-signer.md create mode 100644 docs/zh/latest/plugins/xml-signer.md create mode 100644 t/plugin/soap-signer.t create mode 100644 t/plugin/xml-signer.t diff --git a/Makefile b/Makefile index b6df7615ffaf..4da7c3546db2 100644 --- a/Makefile +++ b/Makefile @@ -345,6 +345,12 @@ install: runtime $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/serverless $(ENV_INSTALL) apisix/plugins/serverless/*.lua $(ENV_INST_LUADIR)/apisix/plugins/serverless/ + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/signing + $(ENV_INSTALL) apisix/plugins/signing/*.lua $(ENV_INST_LUADIR)/apisix/plugins/signing/ + + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/soap-signer + $(ENV_INSTALL) apisix/plugins/soap-signer/*.lua $(ENV_INST_LUADIR)/apisix/plugins/soap-signer/ + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/syslog $(ENV_INSTALL) apisix/plugins/syslog/*.lua $(ENV_INST_LUADIR)/apisix/plugins/syslog/ @@ -357,6 +363,9 @@ install: runtime $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/secret $(ENV_INSTALL) apisix/secret/*.lua $(ENV_INST_LUADIR)/apisix/secret/ + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/xml-signer + $(ENV_INSTALL) apisix/plugins/xml-signer/*.lua $(ENV_INST_LUADIR)/apisix/plugins/xml-signer/ + $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/zipkin $(ENV_INSTALL) apisix/plugins/zipkin/*.lua $(ENV_INST_LUADIR)/apisix/plugins/zipkin/ diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index ab034eeea865..7567d72da887 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -244,6 +244,8 @@ local _M = { "data-mask", "proxy-cache", "body-transformer", + "soap-signer", + "xml-signer", "ai-prompt-template", "ai-prompt-decorator", "ai-prompt-guard", diff --git a/apisix/plugins/signing/algorithms.lua b/apisix/plugins/signing/algorithms.lua new file mode 100644 index 000000000000..ed173de55616 --- /dev/null +++ b/apisix/plugins/signing/algorithms.lua @@ -0,0 +1,55 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local digest = require("resty.openssl.digest") + +local DS = "http://www.w3.org/2000/09/xmldsig#" + +local ALGORITHMS = { + ["rsa-sha1"] = { + digest = "sha1", + digest_uri = DS .. "sha1", + signature_uri = DS .. "rsa-sha1", + }, + ["rsa-sha256"] = { + digest = "sha256", + digest_uri = "http://www.w3.org/2001/04/xmlenc#sha256", + signature_uri = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + }, +} + +local _M = {} + + +function _M.get(name) + return ALGORITHMS[name] +end + + +function _M.hash(value, algorithm) + local context, err = digest.new(algorithm.digest) + if not context then + return nil, err + end + local ok, update_err = context:update(value) + if not ok then + return nil, update_err + end + return context:final() +end + + +return _M \ No newline at end of file diff --git a/apisix/plugins/signing/credentials.lua b/apisix/plugins/signing/credentials.lua new file mode 100644 index 000000000000..56cd681f3b6e --- /dev/null +++ b/apisix/plugins/signing/credentials.lua @@ -0,0 +1,100 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local core = require("apisix.core") +local pkey = require("resty.openssl.pkey") +local resty_sha256 = require("resty.sha256") +local resty_string = require("resty.string") +local x509 = require("resty.openssl.x509") + + +local credential_cache = core.lrucache.new({ + ttl = 300, + count = 64, +}) + +local _M = {} + + +local function fingerprint(certificate_pem, private_key_pem) + local sha256 = resty_sha256:new() + sha256:update(certificate_pem) + sha256:update("\0") + sha256:update(private_key_pem) + return resty_string.to_hex(sha256:final()) +end + + +local function parse_credentials(certificate_pem, private_key_pem) + local private_key, key_err = pkey.new(private_key_pem, { + format = "PEM", + type = "pr", + }) + if not private_key then + return nil, "cannot parse RSA private key: " .. tostring(key_err) + end + local key_type = private_key:get_key_type() + if not key_type or key_type.sn ~= "rsaEncryption" then + return nil, "private key must be RSA" + end + + local certificate, certificate_err = x509.new(certificate_pem, "PEM") + if not certificate then + return nil, "cannot parse X.509 certificate: " .. tostring(certificate_err) + end + + local matches, match_err = certificate:check_private_key(private_key) + if not matches then + return nil, "private key does not match certificate: " .. tostring(match_err) + end + + local certificate_der, der_err = certificate:tostring("DER") + if not certificate_der then + return nil, "cannot encode X.509 certificate: " .. tostring(der_err) + end + + return { + private_key = private_key, + certificate = certificate, + certificate_der = certificate_der, + } +end + + +function _M.load(conf) + if type(conf) ~= "table" then + return nil, "credentials are required" + end + + local certificate_pem = conf.certificate + local private_key_pem = conf.private_key + if type(certificate_pem) ~= "string" or certificate_pem == "" then + return nil, "certificate is required" + end + if type(private_key_pem) ~= "string" or private_key_pem == "" then + return nil, "private key is required" + end + if certificate_pem:sub(1, 1) == "$" or private_key_pem:sub(1, 1) == "$" then + return nil, "credential secret reference could not be resolved" + end + + local cache_key = fingerprint(certificate_pem, private_key_pem) + return credential_cache(cache_key, nil, parse_credentials, + certificate_pem, private_key_pem) +end + + +return _M \ No newline at end of file diff --git a/apisix/plugins/signing/request.lua b/apisix/plugins/signing/request.lua new file mode 100644 index 000000000000..51c71512dc11 --- /dev/null +++ b/apisix/plugins/signing/request.lua @@ -0,0 +1,88 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local core = require("apisix.core") + +local ngx = ngx + +local _M = {} + + +local function contains(values, expected) + for _, value in ipairs(values) do + if value == expected then + return true + end + end + return false +end + + +local function normalized_content_type(value) + if not value then + return "" + end + local normalized = value:match("^%s*([^;]+)") + return normalized and normalized:lower() or "" +end + + +function _M.rewrite(plugin_name, conf, ctx, sign) + if type(conf) ~= "table" then + return 500, {message = "internal_error"} + end + + conf.request = conf.request or {} + conf.request.methods = conf.request.methods or {"POST"} + conf.request.content_types = conf.request.content_types or {"application/xml"} + conf.request.max_body_bytes = conf.request.max_body_bytes or 5242880 + + if not contains(conf.request.methods, ngx.req.get_method()) then + return + end + + local content_type = normalized_content_type( + core.request.header(ctx, "Content-Type")) + if not contains(conf.request.content_types, content_type) then + return 415, {message = "unsupported_media_type"} + end + + local body, body_err = core.request.get_body(conf.request.max_body_bytes, ctx) + if not body then + core.log.error(plugin_name, " failed to read request body") + return body_err and 413 or 400, + {message = body_err and "body_too_large" or "empty_body"} + end + + if conf.signature.algorithm == "rsa-sha1" then + core.log.warn(plugin_name, " is using legacy SHA-1 cryptography") + end + + local signed, code = sign(body, conf) + if not signed then + core.log.error(plugin_name, " rejected request: ", code) + local internal_error = code == "credential_error" or code == "signing_error" + local status = internal_error and 500 or 400 + return status, {message = code or "internal_error"} + end + + ngx.req.set_body_data(signed) + core.request.set_header(ctx, "Content-Length", tostring(#signed)) + ngx.req.clear_header("Transfer-Encoding") +end + + +return _M \ No newline at end of file diff --git a/apisix/plugins/signing/xml.lua b/apisix/plugins/signing/xml.lua new file mode 100644 index 000000000000..b259eea617cc --- /dev/null +++ b/apisix/plugins/signing/xml.lua @@ -0,0 +1,358 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local ffi = require("ffi") + + +ffi.cdef[[ +typedef unsigned char xmlChar; +typedef int xmlElementType; +typedef struct _xmlDoc xmlDoc; +typedef xmlDoc *xmlDocPtr; +typedef struct _xmlNode xmlNode; +typedef xmlNode *xmlNodePtr; +typedef struct _xmlNs xmlNs; +typedef xmlNs *xmlNsPtr; +typedef struct _xmlAttr xmlAttr; +typedef xmlAttr *xmlAttrPtr; +typedef struct _xmlNodeSet xmlNodeSet; +typedef xmlNodeSet *xmlNodeSetPtr; +typedef void (*xmlFreeFunc)(void *mem); +extern xmlFreeFunc xmlFree; + +struct _xmlNs { + xmlNsPtr next; + xmlElementType type; + const xmlChar *href; + const xmlChar *prefix; + void *_private; + xmlDocPtr context; +}; + +struct _xmlAttr { + void *_private; + xmlElementType type; + const xmlChar *name; + xmlNodePtr children; + xmlNodePtr last; + xmlNodePtr parent; + xmlAttrPtr next; + xmlAttrPtr prev; + xmlDocPtr doc; + xmlNsPtr ns; + int atype; + void *psvi; +}; + +struct _xmlNode { + void *_private; + xmlElementType type; + const xmlChar *name; + xmlNodePtr children; + xmlNodePtr last; + xmlNodePtr parent; + xmlNodePtr next; + xmlNodePtr prev; + xmlDocPtr doc; + xmlNsPtr ns; + xmlChar *content; + xmlAttrPtr properties; + xmlNsPtr nsDef; + void *psvi; + unsigned short line; + unsigned short extra; +}; + +void xmlInitParser(void); +xmlDocPtr xmlReadMemory(const char *buffer, int size, const char *URL, + const char *encoding, int options); +void xmlFreeDoc(xmlDocPtr cur); +xmlNodePtr xmlDocGetRootElement(const xmlDoc *doc); +xmlDocPtr xmlNewDoc(const xmlChar *version); +xmlNodePtr xmlDocCopyNode(xmlNodePtr node, xmlDocPtr doc, int extended); +xmlNodePtr xmlDocSetRootElement(xmlDocPtr doc, xmlNodePtr root); +int xmlReconciliateNs(xmlDocPtr doc, xmlNodePtr tree); + +xmlNsPtr xmlNewNs(xmlNodePtr node, const xmlChar *href, const xmlChar *prefix); +xmlNsPtr xmlSearchNs(xmlDocPtr doc, xmlNodePtr node, const xmlChar *name_space); +xmlNsPtr xmlSearchNsByHref(xmlDocPtr doc, xmlNodePtr node, const xmlChar *href); +xmlNodePtr xmlNewNode(xmlNsPtr ns, const xmlChar *name); +xmlNodePtr xmlNewChild(xmlNodePtr parent, xmlNsPtr ns, + const xmlChar *name, const xmlChar *content); +xmlNodePtr xmlAddPrevSibling(xmlNodePtr cur, xmlNodePtr elem); +void xmlUnlinkNode(xmlNodePtr cur); +void xmlFreeNode(xmlNodePtr cur); + +xmlAttrPtr xmlSetNsProp(xmlNodePtr node, xmlNsPtr ns, + const xmlChar *name, const xmlChar *value); +xmlAttrPtr xmlSetProp(xmlNodePtr node, const xmlChar *name, + const xmlChar *value); +xmlChar *xmlGetNsProp(const xmlNodePtr node, const xmlChar *name, + const xmlChar *name_space); +xmlChar *xmlGetProp(const xmlNodePtr node, const xmlChar *name); +xmlChar *xmlNodeGetContent(const xmlNodePtr cur); + +int xmlC14NDocDumpMemory(xmlDocPtr doc, xmlNodeSetPtr nodes, + int mode, xmlChar **inclusive_ns_prefixes, + int with_comments, xmlChar **doc_txt_ptr); +void xmlDocDumpMemoryEnc(xmlDocPtr out_doc, xmlChar **doc_txt_ptr, + int *doc_txt_len, const char *txt_encoding); +]] + +local loaded, C = pcall(ffi.load, "xml2") +if not loaded then + C = ffi.load("libxml2.so.2") +end +C.xmlInitParser() + +local XML_ELEMENT_NODE = 1 +local XML_PARSE_NONET = 2048 +local EXCLUSIVE_C14N = 1 + +local _M = {} + + +local function xml_char(value) + if value == nil then + return nil + end + return ffi.cast("const xmlChar *", value) +end + + +local function copy_and_free(value) + if value == nil then + return nil + end + local result = ffi.string(value) + C.xmlFree(value) + return result +end + + +function _M.parse(value) + if type(value) ~= "string" or value == "" then + return nil, "XML body is empty" + end + + local upper = value:upper() + if upper:find(" 0 then + xml.free_document(doc) + return nil, "already_signed", "SOAP message already contains a signature" + end + + local unique, unique_err = reject_duplicate_ids(envelope.root) + if not unique then + xml.free_document(doc) + return nil, "invalid_soap", unique_err + end + + local runtime, credential_err = credentials.load(conf.credentials) + if not runtime then + xml.free_document(doc) + return nil, "credential_error", credential_err + end + + ensure_namespaces(envelope) + envelope.security = xml.find_child(envelope.header, WSSE, "Security") + if not envelope.security then + envelope.security = xml.new_child(envelope.header, envelope.wsse_ns, "Security") + end + set_must_understand(envelope, conf.soap.must_understand) + + local body_id, body_id_err = ensure_id(envelope.body, envelope.wsu_ns, "Body") + if not body_id then + xml.free_document(doc) + return nil, "signing_error", body_id_err + end + local timestamp, timestamp_err = build_timestamp( + envelope, conf.timestamp.ttl_seconds, now or ngx.time()) + if not timestamp then + xml.free_document(doc) + return nil, "signing_error", timestamp_err + end + local token_id, token_err = build_token(envelope, runtime, key_info_mode) + if key_info_mode == "binary_security_token" and not token_id then + xml.free_document(doc) + return nil, "signing_error", token_err + end + + local signature, signature_err = build_signature( + envelope, runtime, token_id, {envelope.body, timestamp}, algorithm, + key_info_mode) + if not signature then + xml.free_document(doc) + return nil, "signing_error", signature_err + end + + local result, serialize_err = xml.serialize(doc) + xml.free_document(doc) + if not result then + return nil, "signing_error", serialize_err + end + return result +end + + +return _M \ No newline at end of file diff --git a/apisix/plugins/xml-signer.lua b/apisix/plugins/xml-signer.lua new file mode 100644 index 000000000000..d00ba0fa1eae --- /dev/null +++ b/apisix/plugins/xml-signer.lua @@ -0,0 +1,124 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local core = require("apisix.core") +local request = require("apisix.plugins.signing.request") +local signer = require("apisix.plugins.xml-signer.signer") + +local plugin_name = "xml-signer" + +local schema = { + type = "object", + additionalProperties = false, + properties = { + credentials = { + type = "object", + additionalProperties = false, + properties = { + certificate = {type = "string", minLength = 1}, + private_key = {type = "string", minLength = 1}, + }, + required = {"certificate", "private_key"}, + }, + signature = { + type = "object", + additionalProperties = false, + default = {}, + properties = { + algorithm = { + type = "string", + enum = {"rsa-sha1", "rsa-sha256"}, + default = "rsa-sha256", + }, + key_info = { + type = "string", + enum = {"x509_data", "none"}, + default = "x509_data", + }, + }, + }, + request = { + type = "object", + additionalProperties = false, + default = {}, + properties = { + methods = { + type = "array", + minItems = 1, + uniqueItems = true, + items = {type = "string", minLength = 1}, + default = {"POST"}, + }, + content_types = { + type = "array", + minItems = 1, + uniqueItems = true, + items = {type = "string", minLength = 1}, + default = {"application/xml", "text/xml"}, + }, + max_body_bytes = { + type = "integer", + minimum = 1024, + maximum = 67108864, + default = 5242880, + }, + }, + }, + }, + encrypt_fields = {"credentials.private_key"}, + required = {"credentials"}, +} + +local _M = { + version = 0.1, + priority = 1026, + name = plugin_name, + schema = schema, +} + + +local function set_default(target, key, value) + if target[key] == nil then + target[key] = value + end +end + + +local function normalize_conf(original) + local conf = core.table.deepcopy(original or {}) + conf.signature = conf.signature or {} + set_default(conf.signature, "algorithm", "rsa-sha256") + set_default(conf.signature, "key_info", "x509_data") + conf.request = conf.request or {} + set_default(conf.request, "methods", {"POST"}) + set_default(conf.request, "content_types", {"application/xml", "text/xml"}) + set_default(conf.request, "max_body_bytes", 5242880) + return conf +end + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +function _M.rewrite(conf, ctx) + conf = normalize_conf(conf) + return request.rewrite(plugin_name, conf, ctx, signer.sign) +end + + +return _M \ No newline at end of file diff --git a/apisix/plugins/xml-signer/signer.lua b/apisix/plugins/xml-signer/signer.lua new file mode 100644 index 000000000000..a9ac9237b974 --- /dev/null +++ b/apisix/plugins/xml-signer/signer.lua @@ -0,0 +1,141 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local algorithms = require("apisix.plugins.signing.algorithms") +local credentials = require("apisix.plugins.signing.credentials") +local xml = require("apisix.plugins.signing.xml") + +local encode_base64 = ngx.encode_base64 + +local DS = "http://www.w3.org/2000/09/xmldsig#" +local ENVELOPED_SIGNATURE = DS .. "enveloped-signature" +local EXCLUSIVE_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#" + +local _M = { + namespaces = {ds = DS}, +} + + +local function build_signature(doc, root, runtime, algorithm, key_info_mode, + digest_value) + local ds_ns = xml.ensure_namespace(doc, root, "ds", DS) + local signature = xml.new_child(root, ds_ns, "Signature") + local signed_info = xml.new_child(signature, ds_ns, "SignedInfo") + + local canonicalization_method = xml.new_child( + signed_info, ds_ns, "CanonicalizationMethod") + xml.set_property(canonicalization_method, "Algorithm", EXCLUSIVE_C14N) + + local signature_method = xml.new_child(signed_info, ds_ns, "SignatureMethod") + xml.set_property(signature_method, "Algorithm", algorithm.signature_uri) + + local reference = xml.new_child(signed_info, ds_ns, "Reference") + xml.set_property(reference, "URI", "") + local transforms = xml.new_child(reference, ds_ns, "Transforms") + local enveloped = xml.new_child(transforms, ds_ns, "Transform") + xml.set_property(enveloped, "Algorithm", ENVELOPED_SIGNATURE) + local canonicalization = xml.new_child(transforms, ds_ns, "Transform") + xml.set_property(canonicalization, "Algorithm", EXCLUSIVE_C14N) + + local digest_method = xml.new_child(reference, ds_ns, "DigestMethod") + xml.set_property(digest_method, "Algorithm", algorithm.digest_uri) + xml.new_child(reference, ds_ns, "DigestValue", encode_base64(digest_value)) + + local canonical, canonical_err = xml.canonicalize(signed_info) + if not canonical then + return nil, canonical_err + end + + local signature_value, signature_err = runtime.private_key:sign( + canonical, algorithm.digest) + if not signature_value then + return nil, signature_err + end + xml.new_child(signature, ds_ns, "SignatureValue", encode_base64(signature_value)) + + if key_info_mode == "x509_data" then + local key_info = xml.new_child(signature, ds_ns, "KeyInfo") + local x509_data = xml.new_child(key_info, ds_ns, "X509Data") + xml.new_child(x509_data, ds_ns, "X509Certificate", + encode_base64(runtime.certificate_der)) + end + return signature +end + + +function _M.sign(body, conf) + local algorithm_name = conf.signature and conf.signature.algorithm or "rsa-sha256" + local algorithm = algorithms.get(algorithm_name) + if not algorithm then + return nil, "signing_error", "unsupported signature algorithm" + end + local key_info_mode = conf.signature and conf.signature.key_info or "x509_data" + if key_info_mode ~= "x509_data" and key_info_mode ~= "none" then + return nil, "signing_error", "unsupported KeyInfo mode" + end + + local doc, parse_err = xml.parse(body) + if not doc then + return nil, "invalid_xml", parse_err + end + + local root = xml.root(doc) + if not root then + xml.free_document(doc) + return nil, "invalid_xml", "XML document has no root element" + end + + if #xml.find_all(root, DS, "Signature") > 0 then + xml.free_document(doc) + return nil, "already_signed", "XML document already contains a signature" + end + + local runtime, credential_err = credentials.load(conf.credentials) + if not runtime then + xml.free_document(doc) + return nil, "credential_error", credential_err + end + + xml.ensure_namespace(doc, root, "ds", DS) + local canonical, canonical_err = xml.canonicalize_document(doc) + if not canonical then + xml.free_document(doc) + return nil, "signing_error", canonical_err + end + + local digest_value, digest_err = algorithms.hash(canonical, algorithm) + if not digest_value then + xml.free_document(doc) + return nil, "signing_error", digest_err + end + + local signature, signature_err = build_signature( + doc, root, runtime, algorithm, key_info_mode, digest_value) + if not signature then + xml.free_document(doc) + return nil, "signing_error", signature_err + end + + local result, serialize_err = xml.serialize(doc) + xml.free_document(doc) + if not result then + return nil, "signing_error", serialize_err + end + return result +end + + +return _M \ No newline at end of file diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 6a1361aec496..c250ecf17989 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -601,6 +601,8 @@ plugins: # plugin list (sorted by priority) - ai-rate-limiting # priority: 1030 - ai-aliyun-content-moderation # priority: 1029 - ai-lakera-guard # priority: 1028 + - soap-signer # priority: 1027 + - xml-signer # priority: 1026 - proxy-mirror # priority: 1010 - graphql-proxy-cache # priority: 1009 - proxy-rewrite # priority: 1008 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index bf2424a500ac..de688ab0c4a8 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -117,6 +117,8 @@ "plugins/mocking", "plugins/degraphql", "plugins/body-transformer", + "plugins/soap-signer", + "plugins/xml-signer", "plugins/attach-consumer-label", "plugins/exit-transformer" ] diff --git a/docs/en/latest/plugins/soap-signer.md b/docs/en/latest/plugins/soap-signer.md new file mode 100644 index 000000000000..4f45238c484d --- /dev/null +++ b/docs/en/latest/plugins/soap-signer.md @@ -0,0 +1,359 @@ +--- +title: soap-signer +keywords: + - Apache APISIX + - API Gateway + - Plugin + - SOAP + - WS-Security +description: The soap-signer Plugin signs SOAP request bodies with an X.509 WS-Security signature. +--- + + + +## Description + +The `soap-signer` Plugin signs SOAP 1.1 and SOAP 1.2 request bodies using +WS-Security. It adds a `wsse:Security` header containing a Timestamp, an X.509 +BinarySecurityToken, and an XML Signature over the SOAP Body and Timestamp. + +The Plugin supports RSA-SHA256 for normal use and RSA-SHA1 for interoperability +with legacy SOAP services. It uses exclusive XML canonicalization without +comments. By default, it references an embedded certificate through a +WS-Security `SecurityTokenReference`; KeyInfo and the token can be omitted when +the verifier obtains the certificate out of band. + +Use exactly one of `soap-signer` and `xml-signer` for a request. SOAP is XML, +and both plugins can accept `application/xml`; enabling both causes the second +plugin to reject the already-signed message. + +## How it works + +For a matching request, the Plugin performs the following operations during the +rewrite phase: + +1. Buffers and parses the SOAP XML with external network access disabled. +2. Detects SOAP 1.1 or SOAP 1.2 from the Envelope namespace and validates it + against `soap.version`. +3. Preserves an existing SOAP Header or creates one before the Body. +4. Creates or reuses `wsse:Security` and sets the SOAP `mustUnderstand` + attribute. +5. Assigns a `wsu:Id` to the Body when it does not already have one. +6. Creates a UTC Timestamp with `Created`, `Expires`, and a unique `wsu:Id`. +7. Embeds the X.509 certificate in a `wsse:BinarySecurityToken` unless + `signature.key_info` is `none`. +8. Calculates a digest for both the Body and Timestamp using the algorithm + selected by `signature.algorithm`. +9. Signs canonicalized `ds:SignedInfo` and updates the upstream request body. + +The generated security header has this general structure: + +```xml + + + ... + ... + + ... + + + ... + ... + + ... + + + + + + + +``` + +## Requirements + +- APISIX must run on Linux with `libxml2.so.2` available. The APISIX Debian + images include this library. +- The certificate and private key must be PEM encoded, use RSA, and form a + matching key pair. +- The SOAP service must support X.509 BinarySecurityToken direct references + and exclusive XML canonicalization. +- Configure the Plugin after any other Plugin that modifies the SOAP body or + signed headers. Any later modification invalidates the signature. + +## Attributes + +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `credentials` | object | True | | Signing credentials. | +| `credentials.certificate` | string | True | | PEM-encoded X.509 certificate or an APISIX Secret reference. | +| `credentials.private_key` | string | True | | PEM-encoded RSA private key or an APISIX Secret reference. | +| `signature` | object | False | `{}` | Signature configuration. | +| `signature.algorithm` | string | False | `rsa-sha256` | Signature and reference digest algorithm. Valid values are `rsa-sha256` and `rsa-sha1`. | +| `signature.key_info` | string | False | `binary_security_token` | Key information mode. Use `binary_security_token` for a direct X.509 token reference or `none` to omit both `ds:KeyInfo` and the token. | +| `soap.version` | string | False | `auto` | SOAP version: `auto`, `1.1`, or `1.2`. | +| `soap.must_understand` | boolean | False | `true` | Value of the SOAP `mustUnderstand` attribute on the Security header. | +| `timestamp.ttl_seconds` | integer | False | `300` | Timestamp lifetime. Valid range: 1 to 86400 seconds. | +| `request.methods` | array[string] | False | `["POST"]` | Request methods to sign. Other methods pass through unchanged. | +| `request.content_types` | array[string] | False | `["text/xml", "application/soap+xml", "application/xml"]` | Accepted media types. | +| `request.max_body_bytes` | integer | False | `5242880` | Maximum request body size in bytes. Valid range: 1024 to 67108864. | + +NOTE: `encrypt_fields = {"credentials.private_key"}` is defined in the +schema, so a directly configured private key is encrypted in etcd. Using +[APISIX Secret](../terminology/secret.md) references is recommended so key +material is not stored in Route configuration. + +### Signature algorithms + +| Value | SignatureMethod URI | DigestMethod URI | Usage | +| --- | --- | --- | --- | +| `rsa-sha256` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` | `http://www.w3.org/2001/04/xmlenc#sha256` | Default and recommended. | +| `rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#sha1` | Legacy WS-Security compatibility only. | + +:::warning + +SHA-1 is cryptographically weak and should only be enabled when a legacy SOAP +service cannot validate RSA-SHA256 signatures. APISIX logs a warning whenever +it signs a request with `rsa-sha1`. Use TLS, restrict network access, rotate +credentials regularly, and plan migration to RSA-SHA256. + +::: + +### SOAP version behavior + +| `soap.version` | Behavior | +| --- | --- | +| `auto` | Accept SOAP 1.1 or SOAP 1.2 and derive attributes from the Envelope namespace. | +| `1.1` | Require `http://schemas.xmlsoap.org/soap/envelope/`. `mustUnderstand` is serialized as `1` or `0`. | +| `1.2` | Require `http://www.w3.org/2003/05/soap-envelope`. `mustUnderstand` is serialized as `true` or `false`. | + +## Configure credentials + +Use [APISIX Secret](../terminology/secret.md) references to keep private key +material out of Route configuration. The format is: + +```text +$secret://// +``` + +For example: + +```json +{ + "credentials": { + "certificate": "$secret://vault/signing/soap/certificate", + "private_key": "$secret://vault/signing/soap/private-key" + } +} +``` + +APISIX resolves references before the Plugin executes. Parsed credentials are +cached per worker using a fingerprint of the resolved values. Rotated Secret +values are used when the APISIX Secret cache refreshes; a worker restart is not +required. + +### Use environment variables + +Without an external secret manager, such as on Kubernetes, expose the PEM +values as environment variables and reference them with `$env://`. + +1. Create a Kubernetes Secret and inject it into the APISIX Pod: + + ```shell + kubectl create secret generic soap-signing \ + --from-file=cert=signer.crt --from-file=key=signer.key + ``` + + ```yaml + env: + - name: SIGNER_CERT + valueFrom: { secretKeyRef: { name: soap-signing, key: cert } } + - name: SIGNER_KEY + valueFrom: { secretKeyRef: { name: soap-signing, key: key } } + ``` + +2. Allow the variables in the NGINX workers in `config.yaml`: + + ```yaml + nginx_config: + envs: + - SIGNER_CERT + - SIGNER_KEY + ``` + +3. Reference them in the Plugin: + + ```json + { + "credentials": { + "certificate": "$env://SIGNER_CERT", + "private_key": "$env://SIGNER_KEY" + } + } + ``` + +Changed environment values require restarting the APISIX Pods. + +## Enable Plugin + +The following example enables SOAP version auto-detection and RSA-SHA256: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/1 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-soap", + "plugins": { + "soap-signer": { + "credentials": { + "certificate": "$secret://vault/signing/soap/certificate", + "private_key": "$secret://vault/signing/soap/private-key" + }, + "signature": {"algorithm": "rsa-sha256"}, + "soap": {"version": "auto", "must_understand": true}, + "timestamp": {"ttl_seconds": 300} + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +For a legacy service that only accepts SHA-1, use: + +```json +{ + "signature": {"algorithm": "rsa-sha1"} +} +``` + +This changes both Body/Timestamp digests and the SignedInfo signature. It does +not change canonicalization or the BinarySecurityToken format. + +## Disable KeyInfo + +Set `signature.key_info` to `none` when the SOAP service already has the signing +certificate and expects no `ds:KeyInfo`. In this mode, the Plugin also omits +`wsse:BinarySecurityToken` because no generated element references it. The Body +and Timestamp remain signed. + +The Plugin still requires both credentials: the private key signs the message +and the certificate validates that the configured key pair matches. + +The following Admin API request creates a SOAP Route without KeyInfo: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/2 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-soap-without-key-info", + "plugins": { + "soap-signer": { + "credentials": { + "certificate": "$secret://vault/signing/soap/certificate", + "private_key": "$secret://vault/signing/soap/private-key" + }, + "signature": { + "algorithm": "rsa-sha256", + "key_info": "none" + }, + "soap": {"version": "auto", "must_understand": true}, + "timestamp": {"ttl_seconds": 300} + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +The resulting `wsse:Security` header contains the Timestamp and `ds:Signature`, +including references to the Body and Timestamp, but contains no +`wsse:BinarySecurityToken` or `ds:KeyInfo`. The upstream must select the trusted +verification certificate through its own configuration. + +## Example usage + +Send a SOAP 1.2 request: + +```shell +curl http://127.0.0.1:9080/signed-soap \ + -H "Content-Type: application/soap+xml" \ + -d '' +``` + +For SOAP 1.1, use `Content-Type: text/xml` and the SOAP 1.1 Envelope namespace: + +```shell +curl http://127.0.0.1:9080/signed-soap \ + -H "Content-Type: text/xml; charset=utf-8" \ + -d '' +``` + +The upstream receives the original SOAP message with a signed WS-Security +header. Requests whose method is not listed in `request.methods` pass through +unchanged. + +## Error responses + +| HTTP status | Message | Cause | +| --- | --- | --- | +| `400` | `empty_body` | The selected request has no body. | +| `400` | `invalid_soap` | XML parsing failed, Body is absent, duplicate `wsu:Id` values exist, or a DTD/entity declaration is present. | +| `400` | `not_soap` | The root is not a supported SOAP Envelope or does not match `soap.version`. | +| `400` | `already_signed` | The message already contains a `ds:Signature`. | +| `413` | `body_too_large` | The body exceeds `request.max_body_bytes`. | +| `415` | `unsupported_media_type` | The selected method uses a Content-Type not listed in `request.content_types`. | +| `500` | `credential_error` | A Secret could not be resolved, PEM parsing failed, the key is not RSA, or the certificate and key do not match. | +| `500` | `signing_error` | ID generation, canonicalization, digest generation, signing, or serialization failed. | + +Detailed cryptographic and request content is not returned to clients. + +## Limitations + +- Only the SOAP Body and generated Timestamp are signed. Custom SOAP headers + cannot currently be added as signature targets. +- KeyInfo can use an X.509 BinarySecurityToken direct reference or be omitted. + Other KeyInfo formats are not supported. +- Actor/role targeting, thumbprint references, ECDSA, RSA-PSS, and replacing + existing signatures are not supported. +- The complete request body is buffered before signing; signing is not a + streaming operation. + +## Troubleshooting + +- If the service reports a digest mismatch, confirm no lower-priority Plugin or + upstream proxy modifies the Body, Timestamp, namespace declarations, or IDs. +- If APISIX returns `not_soap`, compare `soap.version` with the Envelope + namespace rather than relying only on Content-Type. +- If APISIX returns `credential_error`, verify the Secret URI, PEM boundaries, + RSA key type, and certificate/private-key match. +- If a legacy service rejects RSA-SHA256, compare its expected algorithm URIs + with the table above before enabling `rsa-sha1`. +- If the service rejects `mustUnderstand`, verify whether it expects SOAP 1.1 + numeric values or SOAP 1.2 boolean values. + +## Delete Plugin + +Remove `soap-signer` from the Route Plugin configuration and update the Route +through the Admin API. APISIX applies the change without a restart. diff --git a/docs/en/latest/plugins/xml-signer.md b/docs/en/latest/plugins/xml-signer.md new file mode 100644 index 000000000000..1484fdb8e2ea --- /dev/null +++ b/docs/en/latest/plugins/xml-signer.md @@ -0,0 +1,336 @@ +--- +title: xml-signer +keywords: + - Apache APISIX + - API Gateway + - Plugin + - XMLDSig + - XML signature +description: The xml-signer Plugin applies an enveloped XML Digital Signature to an XML request body. +--- + + + +## Description + +The `xml-signer` Plugin applies an enveloped +[XML Digital Signature](https://www.w3.org/TR/xmldsig-core1/) to an XML request +body before proxying it upstream. The generated signature covers the complete +XML document, excluding the generated `ds:Signature` element through the +enveloped-signature transform. + +The Plugin supports RSA-SHA256 for normal use and RSA-SHA1 for interoperability +with legacy systems. It uses exclusive XML canonicalization without comments. +By default, it includes the configured X.509 certificate in `ds:KeyInfo`; this +element can be omitted when the verifier obtains the certificate out of band. + +Use exactly one of `xml-signer` and `soap-signer` for a request. SOAP is XML, +and both plugins can accept `application/xml`; enabling both causes the second +plugin to reject the already-signed message. + +## How it works + +For a matching request, the Plugin performs the following operations during the +rewrite phase: + +1. Buffers the request body up to `request.max_body_bytes`. +2. Parses the XML with external network access disabled. +3. Rejects DTD and entity declarations and documents that already contain a + `ds:Signature` element. +4. Applies exclusive XML canonicalization to the document. +5. Calculates the reference digest selected by `signature.algorithm`. +6. Creates an enveloped `ds:Signature` under the document root. +7. Adds X.509 `ds:KeyInfo` unless `signature.key_info` is `none`. +8. Canonicalizes and signs `ds:SignedInfo` with the configured RSA private key. +9. Replaces the upstream request body and updates `Content-Length`. + +The body is serialized as UTF-8 after signing. Insignificant formatting such as +indentation or quote style can therefore differ from the original request. + +The generated signature has the following structure: + +```xml + + + + + + + + + + + ... + + + ... + + ... + + +``` + +## Requirements + +- APISIX must run on Linux with `libxml2.so.2` available. The APISIX Debian + images include this library. +- The certificate and private key must be PEM encoded, use RSA, and form a + matching key pair. +- The upstream verifier must support exclusive XML canonicalization. +- Configure the Plugin after any other Plugin that modifies the request body. + Any later body modification invalidates the signature. + +## Attributes + +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `credentials` | object | True | | Signing credentials. | +| `credentials.certificate` | string | True | | PEM-encoded X.509 certificate or an APISIX Secret reference. | +| `credentials.private_key` | string | True | | PEM-encoded RSA private key or an APISIX Secret reference. | +| `signature` | object | False | `{}` | Signature configuration. | +| `signature.algorithm` | string | False | `rsa-sha256` | Signature and reference digest algorithm. Valid values are `rsa-sha256` and `rsa-sha1`. | +| `signature.key_info` | string | False | `x509_data` | Key information included in the signature. Use `x509_data` to embed the certificate or `none` to omit `ds:KeyInfo`. | +| `request.methods` | array[string] | False | `["POST"]` | Request methods to sign. Other methods pass through unchanged. | +| `request.content_types` | array[string] | False | `["application/xml", "text/xml"]` | Accepted media types. Parameters such as `charset` are ignored. | +| `request.max_body_bytes` | integer | False | `5242880` | Maximum request body size in bytes. Valid range: 1024 to 67108864. | + +NOTE: `encrypt_fields = {"credentials.private_key"}` is defined in the +schema, so a directly configured private key is encrypted in etcd. Using +[APISIX Secret](../terminology/secret.md) references is recommended so key +material is not stored in Route configuration. + +### Signature algorithms + +| Value | SignatureMethod URI | DigestMethod URI | Usage | +| --- | --- | --- | --- | +| `rsa-sha256` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` | `http://www.w3.org/2001/04/xmlenc#sha256` | Default and recommended. | +| `rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#sha1` | Legacy compatibility only. | + +:::warning + +SHA-1 is cryptographically weak and should only be enabled when a legacy +upstream cannot validate RSA-SHA256 signatures. APISIX logs a warning whenever +it signs a request with `rsa-sha1`. Use compensating controls such as TLS, +restricted network access, short credential rotation intervals, and a migration +plan to RSA-SHA256. + +::: + +## Configure credentials + +You can configure PEM values directly, but Secret references keep private key +material out of Route configuration. The reference format is: + +```text +$secret://// +``` + +For example, after creating an APISIX Vault Secret resource named `signing`, a +certificate and key can be referenced as follows: + +```json +{ + "credentials": { + "certificate": "$secret://vault/signing/xml/certificate", + "private_key": "$secret://vault/signing/xml/private-key" + } +} +``` + +APISIX resolves references before the Plugin executes. Parsed credentials are +cached per worker using a fingerprint of the resolved values. When the Secret +cache returns rotated PEM values, the Plugin parses and uses the new key pair +without requiring an APISIX worker restart. + +### Use environment variables + +Without an external secret manager, such as on Kubernetes, expose the PEM +values as environment variables and reference them with `$env://`. + +1. Create a Kubernetes Secret and inject it into the APISIX Pod: + + ```shell + kubectl create secret generic xml-signing \ + --from-file=cert=signer.crt --from-file=key=signer.key + ``` + + ```yaml + env: + - name: SIGNER_CERT + valueFrom: { secretKeyRef: { name: xml-signing, key: cert } } + - name: SIGNER_KEY + valueFrom: { secretKeyRef: { name: xml-signing, key: key } } + ``` + +2. Allow the variables in the NGINX workers in `config.yaml`: + + ```yaml + nginx_config: + envs: + - SIGNER_CERT + - SIGNER_KEY + ``` + +3. Reference them in the Plugin: + + ```json + { + "credentials": { + "certificate": "$env://SIGNER_CERT", + "private_key": "$env://SIGNER_KEY" + } + } + ``` + +Changed environment values require restarting the APISIX Pods. + +## Enable Plugin + +The following example enables RSA-SHA256 signing on a Route: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/1 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-xml", + "plugins": { + "xml-signer": { + "credentials": { + "certificate": "$secret://vault/signing/xml/certificate", + "private_key": "$secret://vault/signing/xml/private-key" + }, + "signature": {"algorithm": "rsa-sha256"}, + "request": {"max_body_bytes": 5242880} + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +To communicate with a legacy system that only accepts SHA-1, change the +signature configuration: + +```json +{ + "signature": {"algorithm": "rsa-sha1"} +} +``` + +No other configuration changes are required. Both `SignatureMethod` and +`DigestMethod` are changed together to prevent mixed-algorithm signatures. + +## Disable KeyInfo + +Set `signature.key_info` to `none` when the upstream verifier already has the +signing certificate and requires a signature without `ds:KeyInfo`. The Plugin +still requires both credentials: the private key signs the document and the +certificate is used to validate that the configured key pair matches. + +The following Admin API request creates a Route that emits no `ds:KeyInfo`: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/2 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-xml-without-key-info", + "plugins": { + "xml-signer": { + "credentials": { + "certificate": "$secret://vault/signing/xml/certificate", + "private_key": "$secret://vault/signing/xml/private-key" + }, + "signature": { + "algorithm": "rsa-sha256", + "key_info": "none" + } + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +The resulting XML still contains `ds:SignedInfo`, `ds:SignatureValue`, and the +document digest, but contains no `ds:KeyInfo` or embedded certificate. The +upstream must select the trusted verification certificate through its own +configuration. + +## Example usage + +Send an XML request: + +```shell +curl http://127.0.0.1:9080/signed-xml \ + -H "Content-Type: application/xml" \ + -d '42' +``` + +The upstream receives the body with one enveloped `ds:Signature` under the +document root. Requests whose method is not listed in `request.methods` pass +through unchanged. + +## Error responses + +| HTTP status | Message | Cause | +| --- | --- | --- | +| `400` | `empty_body` | The selected request has no body. | +| `400` | `invalid_xml` | XML parsing failed, the document has no root, or a DTD/entity declaration is present. | +| `400` | `already_signed` | The document already contains a `ds:Signature`. | +| `413` | `body_too_large` | The body exceeds `request.max_body_bytes`. | +| `415` | `unsupported_media_type` | The selected method uses a Content-Type not listed in `request.content_types`. | +| `500` | `credential_error` | A Secret could not be resolved, PEM parsing failed, the key is not RSA, or the certificate and key do not match. | +| `500` | `signing_error` | Canonicalization, digest generation, signing, or serialization failed. | + +Detailed cryptographic and request content is not returned to clients. + +## Limitations + +- Only enveloped whole-document signatures with `Reference URI=""` are + supported. +- `KeyInfo` can contain X.509 data or be omitted. Other KeyInfo formats are not + supported. +- Detached signatures, XPath transforms, ECDSA, RSA-PSS, and re-signing an + already signed document are not supported. +- The complete request body is buffered in memory or a temporary request-body + file before signing; this is not a streaming transform. + +## Troubleshooting + +- If the upstream reports a digest mismatch, confirm no Plugin with a lower + priority changes the body after `xml-signer`. +- If APISIX returns `credential_error`, verify the Secret URI, PEM boundaries, + RSA key type, and certificate/private-key match. +- If a legacy verifier rejects the signature, compare its required algorithm + URIs with the table above and confirm it supports exclusive C14N. +- If requests return `415`, include the XML media type in + `request.content_types` or correct the client Content-Type header. + +## Delete Plugin + +Remove `xml-signer` from the Route Plugin configuration and update the Route +through the Admin API. APISIX applies the change without a restart. diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index 36da7ac936a0..88bd0cf8650e 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -106,6 +106,8 @@ "plugins/mocking", "plugins/degraphql", "plugins/body-transformer", + "plugins/soap-signer", + "plugins/xml-signer", "plugins/attach-consumer-label", "plugins/exit-transformer" ] diff --git a/docs/zh/latest/plugins/soap-signer.md b/docs/zh/latest/plugins/soap-signer.md new file mode 100644 index 000000000000..6868e623a5dd --- /dev/null +++ b/docs/zh/latest/plugins/soap-signer.md @@ -0,0 +1,270 @@ +--- +title: soap-signer +keywords: + - Apache APISIX + - API 网关 + - 插件 + - SOAP + - WS-Security +description: soap-signer 插件使用 X.509 WS-Security 签名为 SOAP 请求体签名。 +--- + + + +## 描述 + +`soap-signer` 插件使用 WS-Security 为 SOAP 1.1 和 SOAP 1.2 请求体签名。它会添加 `wsse:Security` 标头,其中包含 Timestamp、X.509 BinarySecurityToken,以及针对 SOAP Body 和 Timestamp 的 XML 签名。 + +插件支持日常使用的 RSA-SHA256,以及用于兼容旧版 SOAP 服务的 RSA-SHA1;使用不带注释的 exclusive XML canonicalization。默认情况下,插件通过 WS-Security `SecurityTokenReference` 引用已嵌入的证书。如果验证方通过带外方式获取证书,也可以省略 KeyInfo 和令牌。 + +同一个请求只能使用 `soap-signer` 和 `xml-signer` 中的一个。SOAP 也是 XML,两个插件都可以接受 `application/xml`;同时启用时,后执行的插件会拒绝已签名的消息。 + +## 工作原理 + +匹配的请求在 rewrite 阶段会依次执行以下操作: + +1. 在禁用外部网络访问的情况下缓冲并解析 SOAP XML。 +2. 根据 Envelope 命名空间识别 SOAP 1.1 或 SOAP 1.2,并按照 `soap.version` 验证版本。 +3. 保留已有的 SOAP Header;若不存在,则在 Body 前创建。 +4. 创建或复用 `wsse:Security`,并设置 SOAP `mustUnderstand` 属性。 +5. 如果 Body 尚无 `wsu:Id`,则为其分配 ID。 +6. 创建包含 `Created`、`Expires` 和唯一 `wsu:Id` 的 UTC Timestamp。 +7. 除非 `signature.key_info` 为 `none`,否则在 `wsse:BinarySecurityToken` 中嵌入 X.509 证书。 +8. 使用 `signature.algorithm` 指定的算法分别计算 Body 和 Timestamp 的摘要。 +9. 对 canonicalized `ds:SignedInfo` 签名,并更新发往上游的请求体。 + +生成的安全标头大致如下: + +```xml + + + ... + ... + + ... + + + ... + ... + + ... + + + + + + + +``` + +## 要求 + +- APISIX 必须运行在 Linux 上,并且系统中提供 `libxml2.so.2`。APISIX Debian 镜像已包含此库。 +- 证书和私钥必须为 PEM 编码,使用 RSA,并且属于同一密钥对。 +- SOAP 服务必须支持 X.509 BinarySecurityToken 直接引用和 exclusive XML canonicalization。 +- 请将插件配置在所有其他修改 SOAP Body 或已签名标头的插件之后。此插件之后的任何修改都会使签名失效。 + +## 属性 + +| 名称 | 类型 | 必选 | 默认值 | 描述 | +| --- | --- | --- | --- | --- | +| `credentials` | object | 是 | | 签名凭据。 | +| `credentials.certificate` | string | 是 | | PEM 编码的 X.509 证书或 APISIX Secret 引用。 | +| `credentials.private_key` | string | 是 | | PEM 编码的 RSA 私钥或 APISIX Secret 引用。 | +| `signature` | object | 否 | `{}` | 签名配置。 | +| `signature.algorithm` | string | 否 | `rsa-sha256` | 签名和引用摘要算法。可选值为 `rsa-sha256` 和 `rsa-sha1`。 | +| `signature.key_info` | string | 否 | `binary_security_token` | 密钥信息模式。使用 `binary_security_token` 直接引用 X.509 令牌,或使用 `none` 同时省略 `ds:KeyInfo` 和令牌。 | +| `soap.version` | string | 否 | `auto` | SOAP 版本:`auto`、`1.1` 或 `1.2`。 | +| `soap.must_understand` | boolean | 否 | `true` | Security 标头中 SOAP `mustUnderstand` 属性的值。 | +| `timestamp.ttl_seconds` | integer | 否 | `300` | Timestamp 有效期,有效范围为 1 到 86400 秒。 | +| `request.methods` | array[string] | 否 | `["POST"]` | 要签名的请求方法。其他方法的请求保持不变并直接转发。 | +| `request.content_types` | array[string] | 否 | `["text/xml", "application/soap+xml", "application/xml"]` | 接受的媒体类型。 | +| `request.max_body_bytes` | integer | 否 | `5242880` | 请求体最大字节数,有效范围为 1024 到 67108864。 | + +注意:schema 中定义了 `encrypt_fields = {"credentials.private_key"}`,因此直接配置的私钥会在 etcd 中加密。建议使用 [APISIX Secret](../terminology/secret.md) 引用,避免密钥材料保存在路由配置中。 + +### 签名算法 + +| 值 | SignatureMethod URI | DigestMethod URI | 用途 | +| --- | --- | --- | --- | +| `rsa-sha256` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` | `http://www.w3.org/2001/04/xmlenc#sha256` | 默认且推荐。 | +| `rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#sha1` | 仅用于兼容旧版 WS-Security。 | + +:::warning + +SHA-1 在密码学上较弱。只有无法验证 RSA-SHA256 签名的旧版 SOAP 服务才应启用该算法。APISIX 每次使用 `rsa-sha1` 对请求签名时都会记录警告。请使用 TLS、限制网络访问、定期轮换凭据,并制定迁移到 RSA-SHA256 的计划。 + +::: + +### SOAP 版本行为 + +| `soap.version` | 行为 | +| --- | --- | +| `auto` | 接受 SOAP 1.1 或 SOAP 1.2,并根据 Envelope 命名空间确定属性。 | +| `1.1` | 要求命名空间为 `http://schemas.xmlsoap.org/soap/envelope/`。`mustUnderstand` 序列化为 `1` 或 `0`。 | +| `1.2` | 要求命名空间为 `http://www.w3.org/2003/05/soap-envelope`。`mustUnderstand` 序列化为 `true` 或 `false`。 | + +## 配置凭据 + +请使用 [APISIX Secret](../terminology/secret.md) 引用,避免私钥材料出现在路由配置中。格式如下: + +```text +$secret://// +``` + +例如: + +```json +{ + "credentials": { + "certificate": "$secret://vault/signing/soap/certificate", + "private_key": "$secret://vault/signing/soap/private-key" + } +} +``` + +APISIX 会在插件执行前解析引用。解析后的凭据会在每个 worker 中按解析值的指纹缓存。APISIX Secret 缓存刷新后,插件会使用轮换后的 Secret 值,无需重启 worker。 + +## 启用插件 + +以下示例启用 SOAP 版本自动检测和 RSA-SHA256: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/1 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-soap", + "plugins": { + "soap-signer": { + "credentials": { + "certificate": "$secret://vault/signing/soap/certificate", + "private_key": "$secret://vault/signing/soap/private-key" + }, + "signature": {"algorithm": "rsa-sha256"}, + "soap": {"version": "auto", "must_understand": true}, + "timestamp": {"ttl_seconds": 300} + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +对于只接受 SHA-1 的旧版服务,请使用: + +```json +{ + "signature": {"algorithm": "rsa-sha1"} +} +``` + +这会同时更改 Body/Timestamp 摘要和 SignedInfo 签名,不会改变 canonicalization 或 BinarySecurityToken 格式。 + +## 禁用 KeyInfo + +如果 SOAP 服务已配置签名证书且要求不包含 `ds:KeyInfo`,请将 `signature.key_info` 设为 `none`。此模式下插件也会省略 `wsse:BinarySecurityToken`,因为没有生成的元素会引用它。Body 和 Timestamp 仍会参与签名。 + +插件仍要求同时提供两种凭据:私钥用于签名,证书用于验证配置的密钥对是否匹配。 + +以下 Admin API 请求会创建不包含 KeyInfo 的 SOAP 路由: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/2 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-soap-without-key-info", + "plugins": { + "soap-signer": { + "credentials": { + "certificate": "$secret://vault/signing/soap/certificate", + "private_key": "$secret://vault/signing/soap/private-key" + }, + "signature": { + "algorithm": "rsa-sha256", + "key_info": "none" + }, + "soap": {"version": "auto", "must_understand": true}, + "timestamp": {"ttl_seconds": 300} + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +生成的 `wsse:Security` 标头仍包含 Timestamp 和 `ds:Signature`,以及对 Body 和 Timestamp 的引用,但不包含 `wsse:BinarySecurityToken` 或 `ds:KeyInfo`。上游必须通过自身配置选择可信的验证证书。 + +## 使用示例 + +发送 SOAP 1.2 请求: + +```shell +curl http://127.0.0.1:9080/signed-soap \ + -H "Content-Type: application/soap+xml" \ + -d '' +``` + +对于 SOAP 1.1,请使用 `Content-Type: text/xml` 和 SOAP 1.1 Envelope 命名空间: + +```shell +curl http://127.0.0.1:9080/signed-soap \ + -H "Content-Type: text/xml; charset=utf-8" \ + -d '' +``` + +上游会收到包含已签名 WS-Security 标头的原始 SOAP 消息。请求方法未列入 `request.methods` 时,请求会保持不变并直接转发。 + +## 错误响应 + +| HTTP 状态码 | 消息 | 原因 | +| --- | --- | --- | +| `400` | `empty_body` | 被选中的请求没有请求体。 | +| `400` | `invalid_soap` | XML 解析失败、Body 缺失、存在重复 `wsu:Id`,或存在 DTD/实体声明。 | +| `400` | `not_soap` | 根元素不是受支持的 SOAP Envelope,或与 `soap.version` 不匹配。 | +| `400` | `already_signed` | 消息已包含 `ds:Signature`。 | +| `413` | `body_too_large` | 请求体超过 `request.max_body_bytes`。 | +| `415` | `unsupported_media_type` | 请求方法匹配,但 Content-Type 未列入 `request.content_types`。 | +| `500` | `credential_error` | Secret 解析失败、PEM 解析失败、密钥不是 RSA,或证书与私钥不匹配。 | +| `500` | `signing_error` | ID 生成、canonicalization、摘要生成、签名或序列化失败。 | + +不会向客户端返回详细的密码学信息或请求内容。 + +## 限制 + +- 仅对 SOAP Body 和生成的 Timestamp 签名;目前不能将自定义 SOAP 标头添加为签名目标。 +- KeyInfo 仅支持 X.509 BinarySecurityToken 直接引用或省略;不支持其他 KeyInfo 格式。 +- 不支持 Actor/Role 定向、thumbprint 引用、ECDSA、RSA-PSS 或替换已有签名。 +- 签名前会缓冲整个请求体;此操作不是流式签名。 + +## 故障排查 + +- 如果服务报告摘要不匹配,请确认没有优先级更低的插件或上游代理修改 Body、Timestamp、命名空间声明或 ID。 +- 如果 APISIX 返回 `not_soap`,请根据 Envelope 命名空间检查 `soap.version`,不要只依赖 Content-Type。 +- 如果 APISIX 返回 `credential_error`,请检查 Secret URI、PEM 边界、RSA 密钥类型以及证书与私钥是否匹配。 +- 如果旧版服务拒绝 RSA-SHA256,请先将其预期算法 URI 与上表比较,再考虑启用 `rsa-sha1`。 +- 如果服务拒绝 `mustUnderstand`,请确认它要求 SOAP 1.1 的数字值还是 SOAP 1.2 的布尔值。 + +## 删除插件 + +从路由插件配置中移除 `soap-signer`,并通过 Admin API 更新路由。APISIX 无需重启即可应用此更改。 \ No newline at end of file diff --git a/docs/zh/latest/plugins/xml-signer.md b/docs/zh/latest/plugins/xml-signer.md new file mode 100644 index 000000000000..30eff52895c6 --- /dev/null +++ b/docs/zh/latest/plugins/xml-signer.md @@ -0,0 +1,248 @@ +--- +title: xml-signer +keywords: + - Apache APISIX + - API 网关 + - 插件 + - XMLDSig + - XML 签名 +description: xml-signer 插件为 XML 请求体添加 enveloped XML 数字签名。 +--- + + + +## 描述 + +`xml-signer` 插件会在请求转发到上游之前,为 XML 请求体添加 enveloped [XML 数字签名](https://www.w3.org/TR/xmldsig-core1/)。签名覆盖整个 XML 文档,并通过 enveloped-signature 转换排除新生成的 `ds:Signature` 元素。 + +插件支持日常使用的 RSA-SHA256,以及用于兼容旧系统的 RSA-SHA1;使用不带注释的 exclusive XML canonicalization。默认情况下,插件会在 `ds:KeyInfo` 中包含配置的 X.509 证书。如果验证方通过带外方式获取证书,也可以省略该元素。 + +同一个请求只能使用 `xml-signer` 和 `soap-signer` 中的一个。SOAP 也是 XML,两个插件都可以接受 `application/xml`;同时启用时,后执行的插件会拒绝已签名的消息。 + +## 工作原理 + +匹配的请求在 rewrite 阶段会依次执行以下操作: + +1. 将请求体缓冲到 `request.max_body_bytes` 限制以内。 +2. 在禁用外部网络访问的情况下解析 XML。 +3. 拒绝 DTD、实体声明以及已包含 `ds:Signature` 元素的文档。 +4. 对文档执行 exclusive XML canonicalization。 +5. 使用 `signature.algorithm` 指定的算法计算引用摘要。 +6. 在文档根元素下创建 enveloped `ds:Signature`。 +7. 除非 `signature.key_info` 为 `none`,否则添加 X.509 `ds:KeyInfo`。 +8. 对 `ds:SignedInfo` 执行 canonicalization,并使用配置的 RSA 私钥签名。 +9. 替换发往上游的请求体并更新 `Content-Length`。 + +签名后的请求体会以 UTF-8 序列化,因此缩进、引号风格等无关格式可能与原请求不同。 + +生成的签名结构如下: + +```xml + + + + + + + + + + + ... + + + ... + + ... + + +``` + +## 要求 + +- APISIX 必须运行在 Linux 上,并且系统中提供 `libxml2.so.2`。APISIX Debian 镜像已包含此库。 +- 证书和私钥必须为 PEM 编码,使用 RSA,并且属于同一密钥对。 +- 上游验证方必须支持 exclusive XML canonicalization。 +- 请将插件配置在所有其他请求体修改插件之后。此插件之后对请求体的任何修改都会使签名失效。 + +## 属性 + +| 名称 | 类型 | 必选 | 默认值 | 描述 | +| --- | --- | --- | --- | --- | +| `credentials` | object | 是 | | 签名凭据。 | +| `credentials.certificate` | string | 是 | | PEM 编码的 X.509 证书或 APISIX Secret 引用。 | +| `credentials.private_key` | string | 是 | | PEM 编码的 RSA 私钥或 APISIX Secret 引用。 | +| `signature` | object | 否 | `{}` | 签名配置。 | +| `signature.algorithm` | string | 否 | `rsa-sha256` | 签名和引用摘要算法。可选值为 `rsa-sha256` 和 `rsa-sha1`。 | +| `signature.key_info` | string | 否 | `x509_data` | 签名中包含的密钥信息。使用 `x509_data` 嵌入证书,或使用 `none` 省略 `ds:KeyInfo`。 | +| `request.methods` | array[string] | 否 | `["POST"]` | 要签名的请求方法。其他方法的请求保持不变并直接转发。 | +| `request.content_types` | array[string] | 否 | `["application/xml", "text/xml"]` | 接受的媒体类型;会忽略 `charset` 等参数。 | +| `request.max_body_bytes` | integer | 否 | `5242880` | 请求体最大字节数,有效范围为 1024 到 67108864。 | + +注意:schema 中定义了 `encrypt_fields = {"credentials.private_key"}`,因此直接配置的私钥会在 etcd 中加密。建议使用 [APISIX Secret](../terminology/secret.md) 引用,避免密钥材料保存在路由配置中。 + +### 签名算法 + +| 值 | SignatureMethod URI | DigestMethod URI | 用途 | +| --- | --- | --- | --- | +| `rsa-sha256` | `http://www.w3.org/2001/04/xmldsig-more#rsa-sha256` | `http://www.w3.org/2001/04/xmlenc#sha256` | 默认且推荐。 | +| `rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#rsa-sha1` | `http://www.w3.org/2000/09/xmldsig#sha1` | 仅用于兼容旧系统。 | + +:::warning + +SHA-1 在密码学上较弱。只有无法验证 RSA-SHA256 签名的旧版上游系统才应启用该算法。APISIX 每次使用 `rsa-sha1` 对请求签名时都会记录警告。请使用 TLS、限制网络访问、缩短凭据轮换周期,并制定迁移到 RSA-SHA256 的计划。 + +::: + +## 配置凭据 + +可以直接配置 PEM 值,但使用 Secret 引用可避免私钥材料出现在路由配置中。引用格式如下: + +```text +$secret://// +``` + +例如,创建名为 `signing` 的 APISIX Vault Secret 资源后,可以按如下方式引用证书和私钥: + +```json +{ + "credentials": { + "certificate": "$secret://vault/signing/xml/certificate", + "private_key": "$secret://vault/signing/xml/private-key" + } +} +``` + +APISIX 会在插件执行前解析引用。解析后的凭据会在每个 worker 中按解析值的指纹缓存。Secret 缓存返回轮换后的 PEM 值时,插件会解析并使用新的密钥对,无需重启 APISIX worker。 + +## 启用插件 + +以下示例在路由上启用 RSA-SHA256 签名: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/1 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-xml", + "plugins": { + "xml-signer": { + "credentials": { + "certificate": "$secret://vault/signing/xml/certificate", + "private_key": "$secret://vault/signing/xml/private-key" + }, + "signature": {"algorithm": "rsa-sha256"}, + "request": {"max_body_bytes": 5242880} + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +若要与只接受 SHA-1 的旧系统通信,请修改签名配置: + +```json +{ + "signature": {"algorithm": "rsa-sha1"} +} +``` + +无需修改其他配置。`SignatureMethod` 和 `DigestMethod` 会一起切换,避免生成混用算法的签名。 + +## 禁用 KeyInfo + +如果上游验证方已配置签名证书且要求签名中不包含 `ds:KeyInfo`,请将 `signature.key_info` 设为 `none`。插件仍要求同时提供两种凭据:私钥用于签名,证书用于验证配置的密钥对是否匹配。 + +以下 Admin API 请求会创建不输出 `ds:KeyInfo` 的路由: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/2 \ + -H "X-API-KEY: $admin_key" -X PUT -d ' +{ + "uri": "/signed-xml-without-key-info", + "plugins": { + "xml-signer": { + "credentials": { + "certificate": "$secret://vault/signing/xml/certificate", + "private_key": "$secret://vault/signing/xml/private-key" + }, + "signature": { + "algorithm": "rsa-sha256", + "key_info": "none" + } + } + }, + "upstream": { + "type": "roundrobin", + "nodes": {"127.0.0.1:9001": 1} + } +}' +``` + +生成的 XML 仍包含 `ds:SignedInfo`、`ds:SignatureValue` 和文档摘要,但不包含 `ds:KeyInfo` 或嵌入的证书。上游必须通过自身配置选择可信的验证证书。 + +## 使用示例 + +发送 XML 请求: + +```shell +curl http://127.0.0.1:9080/signed-xml \ + -H "Content-Type: application/xml" \ + -d '42' +``` + +上游会收到一个在文档根元素下带有 enveloped `ds:Signature` 的请求体。请求方法未列入 `request.methods` 时,请求会保持不变并直接转发。 + +## 错误响应 + +| HTTP 状态码 | 消息 | 原因 | +| --- | --- | --- | +| `400` | `empty_body` | 被选中的请求没有请求体。 | +| `400` | `invalid_xml` | XML 解析失败、文档没有根元素,或存在 DTD/实体声明。 | +| `400` | `already_signed` | 文档已包含 `ds:Signature`。 | +| `413` | `body_too_large` | 请求体超过 `request.max_body_bytes`。 | +| `415` | `unsupported_media_type` | 请求方法匹配,但 Content-Type 未列入 `request.content_types`。 | +| `500` | `credential_error` | Secret 解析失败、PEM 解析失败、密钥不是 RSA,或证书与私钥不匹配。 | +| `500` | `signing_error` | canonicalization、摘要生成、签名或序列化失败。 | + +不会向客户端返回详细的密码学信息或请求内容。 + +## 限制 + +- 仅支持使用 `Reference URI=""` 的 enveloped 全文档签名。 +- `KeyInfo` 仅支持包含 X.509 数据或省略;不支持其他 KeyInfo 格式。 +- 不支持分离式签名、XPath 转换、ECDSA、RSA-PSS 或对已签名文档重新签名。 +- 签名前会将整个请求体缓冲到内存或临时请求体文件中;此操作不是流式转换。 + +## 故障排查 + +- 如果上游报告摘要不匹配,请确认没有优先级低于 `xml-signer` 的插件在其后修改请求体。 +- 如果 APISIX 返回 `credential_error`,请检查 Secret URI、PEM 边界、RSA 密钥类型以及证书与私钥是否匹配。 +- 如果旧版验证方拒绝签名,请将其要求的算法 URI 与上表比较,并确认它支持 exclusive C14N。 +- 如果请求返回 `415`,请在 `request.content_types` 中加入 XML 媒体类型,或修正客户端的 Content-Type 标头。 + +## 删除插件 + +从路由插件配置中移除 `xml-signer`,并通过 Admin API 更新路由。APISIX 无需重启即可应用此更改。 \ No newline at end of file diff --git a/t/plugin/soap-signer.t b/t/plugin/soap-signer.t new file mode 100644 index 000000000000..ddd191ab63ee --- /dev/null +++ b/t/plugin/soap-signer.t @@ -0,0 +1,355 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +no_long_string(); +no_shuffle(); +no_root_location(); + +run_tests; + +__DATA__ + +=== TEST 1: validate schema +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.soap-signer") + local ok = plugin.check_schema({ + credentials = { + certificate = "$secret://vault/signing/certificate", + private_key = "$secret://vault/signing/private-key", + }, + soap = {version = "1.2"}, + timestamp = {ttl_seconds = 60}, + }) + ngx.say(ok) + + ok = plugin.check_schema({ + credentials = { + certificate = "certificate", + private_key = "private-key", + }, + timestamp = {ttl_seconds = 0}, + }) + ngx.say(ok) + + ok = plugin.check_schema({ + credentials = { + certificate = "certificate", + private_key = "private-key", + }, + signature = {algorithm = "rsa-sha1"}, + }) + ngx.say(ok) + + ok = plugin.check_schema({ + credentials = { + certificate = "certificate", + private_key = "private-key", + }, + signature = {algorithm = "rsa-sha512"}, + }) + ngx.say(ok) + } + } +--- request +GET /t +--- response_body +true +false +true +false + + + +=== TEST 2: sign and verify SOAP 1.1 +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local digest = require("resty.openssl.digest") + local x509 = require("resty.openssl.x509") + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local certificate_pem = read("t/certs/server.crt") + local signed = assert(signer.sign([[ + + +]], { + credentials = { + certificate = certificate_pem, + private_key = read("t/certs/server.key"), + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + }, 1790856000)) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + local signatures = xml.find_all(root, signer.namespaces.ds, "Signature") + local signed_info = assert(xml.find_child( + signatures[1], signer.namespaces.ds, "SignedInfo")) + local references = xml.find_all( + signed_info, signer.namespaces.ds, "Reference") + assert(#references == 2) + assert(#xml.find_all( + root, signer.namespaces.wsse, "BinarySecurityToken") == 1) + + local function find_by_id(id) + local found + xml.walk(root, function(node) + if xml.get_namespaced_property( + node, signer.namespaces.wsu, "Id") == id then + found = node + end + end) + return found + end + + for _, reference in ipairs(references) do + local uri = assert(xml.get_property(reference, "URI")) + local target = assert(find_by_id(uri:sub(2))) + local canonical = assert(xml.canonicalize(target)) + local context = assert(digest.new("sha256")) + assert(context:update(canonical)) + local expected = ngx.encode_base64(assert(context:final())) + local value = assert(xml.find_child( + reference, signer.namespaces.ds, "DigestValue")) + assert(xml.content(value) == expected) + end + + local signature_value = assert(xml.find_child( + signatures[1], signer.namespaces.ds, "SignatureValue")) + local canonical = assert(xml.canonicalize(signed_info)) + local raw_signature = assert(ngx.decode_base64(xml.content(signature_value))) + local certificate = assert(x509.new(certificate_pem, "PEM")) + local public_key = assert(certificate:get_pubkey()) + assert(public_key:verify(raw_signature, canonical, "sha256")) + xml.free_document(doc) + ngx.say("verified") + } + } +--- request +GET /t +--- response_body +verified + + + +=== TEST 3: sign SOAP 1.2 and reject existing signatures +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local conf = { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + } + local signed = assert(signer.sign([[ + + +]], conf, 1790856000)) + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + assert(#xml.find_all(root, signer.namespaces.ds, "Signature") == 1) + xml.free_document(doc) + + local _, code = signer.sign(signed, conf, 1790856000) + ngx.say(code) + } + } +--- request +GET /t +--- response_body +already_signed + + + +=== TEST 4: sign and verify SOAP with RSA-SHA1 +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local digest = require("resty.openssl.digest") + local x509 = require("resty.openssl.x509") + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local certificate_pem = read("t/certs/server.crt") + local signed = assert(signer.sign([[ + + +]], { + credentials = { + certificate = certificate_pem, + private_key = read("t/certs/server.key"), + }, + signature = {algorithm = "rsa-sha1"}, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + }, 1790856000)) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + local signature = assert(xml.find_all( + root, signer.namespaces.ds, "Signature")[1]) + local signed_info = assert(xml.find_child( + signature, signer.namespaces.ds, "SignedInfo")) + local signature_method = assert(xml.find_child( + signed_info, signer.namespaces.ds, "SignatureMethod")) + assert(xml.get_property(signature_method, "Algorithm") + == signer.namespaces.ds .. "rsa-sha1") + + local references = xml.find_all( + signed_info, signer.namespaces.ds, "Reference") + assert(#references == 2) + for _, reference in ipairs(references) do + local digest_method = assert(xml.find_child( + reference, signer.namespaces.ds, "DigestMethod")) + assert(xml.get_property(digest_method, "Algorithm") + == signer.namespaces.ds .. "sha1") + end + + local signature_value = assert(xml.find_child( + signature, signer.namespaces.ds, "SignatureValue")) + local canonical_signed_info = assert(xml.canonicalize(signed_info)) + local raw_signature = assert(ngx.decode_base64(xml.content(signature_value))) + local certificate = assert(x509.new(certificate_pem, "PEM")) + local public_key = assert(certificate:get_pubkey()) + assert(public_key:verify(raw_signature, canonical_signed_info, "sha1")) + + local function find_by_id(id) + local found + xml.walk(root, function(node) + if xml.get_namespaced_property( + node, signer.namespaces.wsu, "Id") == id then + found = node + end + end) + return found + end + + for _, reference in ipairs(references) do + local uri = assert(xml.get_property(reference, "URI")) + local target = assert(find_by_id(uri:sub(2))) + local canonical = assert(xml.canonicalize(target)) + local context = assert(digest.new("sha1")) + assert(context:update(canonical)) + local expected = ngx.encode_base64(assert(context:final())) + local value = assert(xml.find_child( + reference, signer.namespaces.ds, "DigestValue")) + assert(xml.content(value) == expected) + end + + xml.free_document(doc) + ngx.say("verified rsa-sha1") + } + } +--- request +GET /t +--- response_body +verified rsa-sha1 + + + +=== TEST 5: sign SOAP without KeyInfo or BinarySecurityToken +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local signed = assert(signer.sign([[ + + +]], { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + signature = { + algorithm = "rsa-sha256", + key_info = "none", + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + }, 1790856000)) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + assert(#xml.find_all(root, signer.namespaces.ds, "Signature") == 1) + assert(#xml.find_all(root, signer.namespaces.ds, "Reference") == 2) + assert(#xml.find_all(root, signer.namespaces.ds, "KeyInfo") == 0) + assert(#xml.find_all( + root, signer.namespaces.wsse, "BinarySecurityToken") == 0) + xml.free_document(doc) + ngx.say("signed without KeyInfo") + } + } +--- request +GET /t +--- response_body +signed without KeyInfo diff --git a/t/plugin/xml-signer.t b/t/plugin/xml-signer.t new file mode 100644 index 000000000000..afb12276b925 --- /dev/null +++ b/t/plugin/xml-signer.t @@ -0,0 +1,433 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +no_long_string(); +no_shuffle(); +no_root_location(); + +run_tests; + +__DATA__ + +=== TEST 1: validate schema +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.xml-signer") + local ok = plugin.check_schema({ + credentials = { + certificate = "$secret://vault/signing/certificate", + private_key = "$secret://vault/signing/private-key", + }, + }) + ngx.say(ok) + + ok = plugin.check_schema({ + credentials = {certificate = "certificate"}, + }) + ngx.say(ok) + + ok = plugin.check_schema({ + credentials = { + certificate = "certificate", + private_key = "private-key", + }, + signature = {algorithm = "rsa-sha1"}, + }) + ngx.say(ok) + + ok = plugin.check_schema({ + credentials = { + certificate = "certificate", + private_key = "private-key", + }, + signature = {algorithm = "rsa-sha512"}, + }) + ngx.say(ok) + } + } +--- request +GET /t +--- response_body +true +false +true +false + + + +=== TEST 2: sign and verify enveloped XMLDSig +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local digest = require("resty.openssl.digest") + local x509 = require("resty.openssl.x509") + local signer = require("apisix.plugins.xml-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local certificate_pem = read("t/certs/server.crt") + local signed = assert(signer.sign( + '' + .. '42' + .. '', + {credentials = { + certificate = certificate_pem, + private_key = read("t/certs/server.key"), + }} + )) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + local signature = assert(xml.find_all( + root, signer.namespaces.ds, "Signature")[1]) + local signed_info = assert(xml.find_child( + signature, signer.namespaces.ds, "SignedInfo")) + local signature_value = assert(xml.find_child( + signature, signer.namespaces.ds, "SignatureValue")) + local reference = assert(xml.find_child( + signed_info, signer.namespaces.ds, "Reference")) + local digest_value = assert(xml.find_child( + reference, signer.namespaces.ds, "DigestValue")) + + local canonical_signed_info = assert(xml.canonicalize(signed_info)) + local raw_signature = assert(ngx.decode_base64(xml.content(signature_value))) + local certificate = assert(x509.new(certificate_pem, "PEM")) + local public_key = assert(certificate:get_pubkey()) + assert(public_key:verify(raw_signature, canonical_signed_info, "sha256")) + + local expected_digest = xml.content(digest_value) + xml.remove(signature) + local canonical_document = assert(xml.canonicalize_document(doc)) + local context = assert(digest.new("sha256")) + assert(context:update(canonical_document)) + assert(ngx.encode_base64(assert(context:final())) == expected_digest) + xml.free_document(doc) + ngx.say("verified") + } + } +--- request +GET /t +--- response_body +verified + + + +=== TEST 3: reject unsafe and already signed XML +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.xml-signer.signer") + local credentials = { + certificate = "unused", + private_key = "unused", + } + local _, code = signer.sign( + ']>' + .. '&xxe;', + {credentials = credentials} + ) + ngx.say(code) + + _, code = signer.sign( + '' + .. '', + {credentials = credentials} + ) + ngx.say(code) + } + } +--- request +GET /t +--- response_body +invalid_xml +already_signed + + + +=== TEST 4: reject non-RSA credentials +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local credentials = require("apisix.plugins.signing.credentials") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local runtime, err = credentials.load({ + certificate = read("t/certs/apisix_ecc.crt"), + private_key = read("t/certs/apisix_ecc.key"), + }) + ngx.say(runtime == nil) + ngx.say(err) + } + } +--- request +GET /t +--- response_body +true +private key must be RSA + + + +=== TEST 5: sign and verify XMLDSig with RSA-SHA1 +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local digest = require("resty.openssl.digest") + local x509 = require("resty.openssl.x509") + local signer = require("apisix.plugins.xml-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local certificate_pem = read("t/certs/server.crt") + local signed = assert(signer.sign( + 'true', + { + credentials = { + certificate = certificate_pem, + private_key = read("t/certs/server.key"), + }, + signature = {algorithm = "rsa-sha1"}, + } + )) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + local signature = assert(xml.find_all( + root, signer.namespaces.ds, "Signature")[1]) + local signed_info = assert(xml.find_child( + signature, signer.namespaces.ds, "SignedInfo")) + local signature_method = assert(xml.find_child( + signed_info, signer.namespaces.ds, "SignatureMethod")) + assert(xml.get_property(signature_method, "Algorithm") + == signer.namespaces.ds .. "rsa-sha1") + + local reference = assert(xml.find_child( + signed_info, signer.namespaces.ds, "Reference")) + local digest_method = assert(xml.find_child( + reference, signer.namespaces.ds, "DigestMethod")) + assert(xml.get_property(digest_method, "Algorithm") + == signer.namespaces.ds .. "sha1") + local digest_value = assert(xml.find_child( + reference, signer.namespaces.ds, "DigestValue")) + + local signature_value = assert(xml.find_child( + signature, signer.namespaces.ds, "SignatureValue")) + local canonical_signed_info = assert(xml.canonicalize(signed_info)) + local raw_signature = assert(ngx.decode_base64(xml.content(signature_value))) + local certificate = assert(x509.new(certificate_pem, "PEM")) + local public_key = assert(certificate:get_pubkey()) + assert(public_key:verify(raw_signature, canonical_signed_info, "sha1")) + + local expected_digest = xml.content(digest_value) + xml.remove(signature) + local canonical_document = assert(xml.canonicalize(root)) + local context = assert(digest.new("sha1")) + assert(context:update(canonical_document)) + assert(ngx.encode_base64(assert(context:final())) == expected_digest) + xml.free_document(doc) + ngx.say("verified rsa-sha1") + } + } +--- request +GET /t +--- response_body +verified rsa-sha1 + + + +=== TEST 6: sign XML without KeyInfo +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.xml-signer.signer") + local xml = require("apisix.plugins.signing.xml") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local signed = assert(signer.sign( + '42', + { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + signature = { + algorithm = "rsa-sha256", + key_info = "none", + }, + } + )) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + assert(#xml.find_all(root, signer.namespaces.ds, "Signature") == 1) + assert(#xml.find_all(root, signer.namespaces.ds, "KeyInfo") == 0) + xml.free_document(doc) + ngx.say("signed without KeyInfo") + } + } +--- request +GET /t +--- response_body +signed without KeyInfo + + + +=== TEST 7: shared request signing pipeline filters and rewrites requests +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local request = require("apisix.plugins.signing.request") + + local original = { + get_method = ngx.req.get_method, + set_body_data = ngx.req.set_body_data, + clear_header = ngx.req.clear_header, + header = core.request.header, + get_body = core.request.get_body, + set_header = core.request.set_header, + } + local method = "POST" + local content_type = "application/xml; charset=utf-8" + local body = "" + local body_err + local rewritten_body + local cleared_header + + ngx.req.get_method = function() + return method + end + ngx.req.set_body_data = function(value) + rewritten_body = value + end + ngx.req.clear_header = function(name) + cleared_header = name + end + core.request.header = function(_, name) + assert(name == "Content-Type") + return content_type + end + core.request.get_body = function() + return body, body_err + end + core.request.set_header = function(ctx, name, value) + ctx[name] = value + end + + local ok, err = pcall(function() + local ctx = {} + local sign_calls = 0 + local function sign(value) + sign_calls = sign_calls + 1 + return value .. "-signed" + end + local conf = { + request = { + methods = {"POST"}, + content_types = {"application/xml"}, + max_body_bytes = 1024, + }, + signature = {algorithm = "rsa-sha256"}, + } + + assert(request.rewrite("xml-signer", conf, ctx, sign) == nil) + assert(sign_calls == 1) + assert(rewritten_body == "-signed") + assert(ctx["Content-Length"] == tostring(#rewritten_body)) + assert(cleared_header == "Transfer-Encoding") + + method = "GET" + assert(request.rewrite("xml-signer", conf, {}, sign) == nil) + assert(sign_calls == 1) + + method = "POST" + content_type = "text/plain" + local status, response = request.rewrite( + "xml-signer", conf, {}, sign) + assert(status == 415) + assert(response.message == "unsupported_media_type") + assert(sign_calls == 1) + + content_type = "application/xml" + body = nil + body_err = "too large" + status, response = request.rewrite("xml-signer", conf, {}, sign) + assert(status == 413) + assert(response.message == "body_too_large") + assert(sign_calls == 1) + end) + + ngx.req.get_method = original.get_method + ngx.req.set_body_data = original.set_body_data + ngx.req.clear_header = original.clear_header + core.request.header = original.header + core.request.get_body = original.get_body + core.request.set_header = original.set_header + assert(ok, err) + ngx.say("request pipeline verified") + } + } +--- request +GET /t +--- response_body +request pipeline verified From 739db3da87b39c32f5a2994dd546f5028721965f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:53:03 +0000 Subject: [PATCH 2/8] Initial plan From 18ef1976af4d7e29b2c54258ce4c5a7148b7879f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:53:58 +0000 Subject: [PATCH 3/8] Apply remaining changes Co-authored-by: keremoge <4692074+keremoge@users.noreply.github.com> --- apisix/plugins/signing/request.lua | 11 ++++++++--- apisix/plugins/signing/xml.lua | 18 ++++++++++++------ apisix/plugins/soap-signer/signer.lua | 21 ++++++++++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apisix/plugins/signing/request.lua b/apisix/plugins/signing/request.lua index 51c71512dc11..a579ba1bac2f 100644 --- a/apisix/plugins/signing/request.lua +++ b/apisix/plugins/signing/request.lua @@ -62,9 +62,14 @@ function _M.rewrite(plugin_name, conf, ctx, sign) local body, body_err = core.request.get_body(conf.request.max_body_bytes, ctx) if not body then - core.log.error(plugin_name, " failed to read request body") - return body_err and 413 or 400, - {message = body_err and "body_too_large" or "empty_body"} + core.log.error(plugin_name, " failed to read request body: ", body_err) + if not body_err then + return 400, {message = "empty_body"} + end + if body_err:find("is greater than the maximum size", 1, true) then + return 413, {message = "body_too_large"} + end + return 500, {message = "internal_error"} end if conf.signature.algorithm == "rsa-sha1" then diff --git a/apisix/plugins/signing/xml.lua b/apisix/plugins/signing/xml.lua index b259eea617cc..0ea7fbcfe56a 100644 --- a/apisix/plugins/signing/xml.lua +++ b/apisix/plugins/signing/xml.lua @@ -119,6 +119,7 @@ end C.xmlInitParser() local XML_ELEMENT_NODE = 1 +local XML_DTD_NODE = 14 local XML_PARSE_NONET = 2048 local EXCLUSIVE_C14N = 1 @@ -148,15 +149,20 @@ function _M.parse(value) return nil, "XML body is empty" end - local upper = value:upper() - if upper:find(" Date: Mon, 5 Oct 2026 11:06:44 +0300 Subject: [PATCH 4/8] fix copilot findings --- apisix/plugins/signing/request.lua | 11 ++++++++--- apisix/plugins/signing/xml.lua | 18 ++++++++++++------ apisix/plugins/soap-signer/signer.lua | 21 ++++++++++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apisix/plugins/signing/request.lua b/apisix/plugins/signing/request.lua index 51c71512dc11..a579ba1bac2f 100644 --- a/apisix/plugins/signing/request.lua +++ b/apisix/plugins/signing/request.lua @@ -62,9 +62,14 @@ function _M.rewrite(plugin_name, conf, ctx, sign) local body, body_err = core.request.get_body(conf.request.max_body_bytes, ctx) if not body then - core.log.error(plugin_name, " failed to read request body") - return body_err and 413 or 400, - {message = body_err and "body_too_large" or "empty_body"} + core.log.error(plugin_name, " failed to read request body: ", body_err) + if not body_err then + return 400, {message = "empty_body"} + end + if body_err:find("is greater than the maximum size", 1, true) then + return 413, {message = "body_too_large"} + end + return 500, {message = "internal_error"} end if conf.signature.algorithm == "rsa-sha1" then diff --git a/apisix/plugins/signing/xml.lua b/apisix/plugins/signing/xml.lua index b259eea617cc..0ea7fbcfe56a 100644 --- a/apisix/plugins/signing/xml.lua +++ b/apisix/plugins/signing/xml.lua @@ -119,6 +119,7 @@ end C.xmlInitParser() local XML_ELEMENT_NODE = 1 +local XML_DTD_NODE = 14 local XML_PARSE_NONET = 2048 local EXCLUSIVE_C14N = 1 @@ -148,15 +149,20 @@ function _M.parse(value) return nil, "XML body is empty" end - local upper = value:upper() - if upper:find(" Date: Mon, 5 Oct 2026 12:23:43 +0300 Subject: [PATCH 5/8] fix review findings --- apisix/cli/config.lua | 4 +- apisix/plugins/signing/credentials.lua | 3 + apisix/plugins/signing/request.lua | 3 + apisix/plugins/signing/xml.lua | 4 +- apisix/plugins/soap-signer/signer.lua | 13 +- docs/en/latest/plugins/soap-signer.md | 7 +- docs/zh/latest/plugins/soap-signer.md | 6 +- t/admin/plugins.t | 2 + t/plugin/soap-signer.t | 177 +++++++++++++++++++++++++ t/plugin/soap-signer2.t | 113 ++++++++++++++++ t/plugin/xml-signer.t | 18 ++- t/plugin/xml-signer2.t | 112 ++++++++++++++++ 12 files changed, 450 insertions(+), 12 deletions(-) create mode 100644 t/plugin/soap-signer2.t create mode 100644 t/plugin/xml-signer2.t diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index 7567d72da887..0fd84c56eaae 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -244,8 +244,6 @@ local _M = { "data-mask", "proxy-cache", "body-transformer", - "soap-signer", - "xml-signer", "ai-prompt-template", "ai-prompt-decorator", "ai-prompt-guard", @@ -257,6 +255,8 @@ local _M = { "ai-aws-content-moderation", "ai-aliyun-content-moderation", "ai-lakera-guard", + "soap-signer", + "xml-signer", "proxy-mirror", "graphql-proxy-cache", "proxy-rewrite", diff --git a/apisix/plugins/signing/credentials.lua b/apisix/plugins/signing/credentials.lua index 56cd681f3b6e..70047abdbce9 100644 --- a/apisix/plugins/signing/credentials.lua +++ b/apisix/plugins/signing/credentials.lua @@ -20,6 +20,9 @@ local resty_sha256 = require("resty.sha256") local resty_string = require("resty.string") local x509 = require("resty.openssl.x509") +local tostring = tostring +local type = type + local credential_cache = core.lrucache.new({ ttl = 300, diff --git a/apisix/plugins/signing/request.lua b/apisix/plugins/signing/request.lua index a579ba1bac2f..4fa76beaa3ce 100644 --- a/apisix/plugins/signing/request.lua +++ b/apisix/plugins/signing/request.lua @@ -16,7 +16,10 @@ -- local core = require("apisix.core") +local ipairs = ipairs local ngx = ngx +local tostring = tostring +local type = type local _M = {} diff --git a/apisix/plugins/signing/xml.lua b/apisix/plugins/signing/xml.lua index 0ea7fbcfe56a..f4c8dd4e7e57 100644 --- a/apisix/plugins/signing/xml.lua +++ b/apisix/plugins/signing/xml.lua @@ -16,6 +16,8 @@ -- local ffi = require("ffi") +local type = type + ffi.cdef[[ typedef unsigned char xmlChar; @@ -84,7 +86,6 @@ xmlNodePtr xmlDocGetRootElement(const xmlDoc *doc); xmlDocPtr xmlNewDoc(const xmlChar *version); xmlNodePtr xmlDocCopyNode(xmlNodePtr node, xmlDocPtr doc, int extended); xmlNodePtr xmlDocSetRootElement(xmlDocPtr doc, xmlNodePtr root); -int xmlReconciliateNs(xmlDocPtr doc, xmlNodePtr tree); xmlNsPtr xmlNewNs(xmlNodePtr node, const xmlChar *href, const xmlChar *prefix); xmlNsPtr xmlSearchNs(xmlDocPtr doc, xmlNodePtr node, const xmlChar *name_space); @@ -339,7 +340,6 @@ function _M.canonicalize(node) return nil, "failed to copy canonicalization node" end C.xmlDocSetRootElement(doc, copy) - C.xmlReconciliateNs(doc, copy) local result, err = canonicalize_document(doc) C.xmlFreeDoc(doc) diff --git a/apisix/plugins/soap-signer/signer.lua b/apisix/plugins/soap-signer/signer.lua index 46e2e6d8b3c7..d8acfcb425a8 100644 --- a/apisix/plugins/soap-signer/signer.lua +++ b/apisix/plugins/soap-signer/signer.lua @@ -21,6 +21,9 @@ local algorithms = require("apisix.plugins.signing.algorithms") local credentials = require("apisix.plugins.signing.credentials") local xml = require("apisix.plugins.signing.xml") +local ipairs = ipairs +local ngx = ngx +local os = os local encode_base64 = ngx.encode_base64 local SOAP11 = "http://schemas.xmlsoap.org/soap/envelope/" @@ -80,6 +83,9 @@ local function find_envelope_parts(doc) if header then return nil, "invalid_soap", "at most one SOAP Header is allowed" end + if body then + return nil, "invalid_soap", "SOAP Header must precede SOAP Body" + end header = child end end @@ -302,6 +308,12 @@ function _M.sign(body, conf, now) return nil, "invalid_soap", unique_err end + envelope.security = xml.find_child(envelope.header, WSSE, "Security") + if envelope.security and xml.find_child(envelope.security, WSU, "Timestamp") then + xml.free_document(doc) + return nil, "invalid_soap", "wsse:Security already contains a wsu:Timestamp" + end + local runtime, credential_err = credentials.load(conf.credentials) if not runtime then xml.free_document(doc) @@ -309,7 +321,6 @@ function _M.sign(body, conf, now) end ensure_namespaces(envelope) - envelope.security = xml.find_child(envelope.header, WSSE, "Security") if not envelope.security then envelope.security = xml.new_child(envelope.header, envelope.wsse_ns, "Security") end diff --git a/docs/en/latest/plugins/soap-signer.md b/docs/en/latest/plugins/soap-signer.md index 4f45238c484d..16f45586ab29 100644 --- a/docs/en/latest/plugins/soap-signer.md +++ b/docs/en/latest/plugins/soap-signer.md @@ -52,9 +52,10 @@ rewrite phase: 1. Buffers and parses the SOAP XML with external network access disabled. 2. Detects SOAP 1.1 or SOAP 1.2 from the Envelope namespace and validates it against `soap.version`. -3. Preserves an existing SOAP Header or creates one before the Body. +3. Preserves an existing SOAP Header, which must precede the Body, or creates + one before the Body. 4. Creates or reuses `wsse:Security` and sets the SOAP `mustUnderstand` - attribute. + attribute. A reused header must not already contain a `wsu:Timestamp`. 5. Assigns a `wsu:Id` to the Body when it does not already have one. 6. Creates a UTC Timestamp with `Created`, `Expires`, and a unique `wsu:Id`. 7. Embeds the X.509 certificate in a `wsse:BinarySecurityToken` unless @@ -319,7 +320,7 @@ unchanged. | HTTP status | Message | Cause | | --- | --- | --- | | `400` | `empty_body` | The selected request has no body. | -| `400` | `invalid_soap` | XML parsing failed, Body is absent, duplicate `wsu:Id` values exist, or a DTD/entity declaration is present. | +| `400` | `invalid_soap` | XML parsing failed, Body is absent or duplicated, Header is duplicated or follows the Body, duplicate `wsu:Id` values exist, the `wsse:Security` header already contains a `wsu:Timestamp`, or a DTD/entity declaration is present. | | `400` | `not_soap` | The root is not a supported SOAP Envelope or does not match `soap.version`. | | `400` | `already_signed` | The message already contains a `ds:Signature`. | | `413` | `body_too_large` | The body exceeds `request.max_body_bytes`. | diff --git a/docs/zh/latest/plugins/soap-signer.md b/docs/zh/latest/plugins/soap-signer.md index 6868e623a5dd..a3f22c210082 100644 --- a/docs/zh/latest/plugins/soap-signer.md +++ b/docs/zh/latest/plugins/soap-signer.md @@ -42,8 +42,8 @@ description: soap-signer 插件使用 X.509 WS-Security 签名为 SOAP 请求体 1. 在禁用外部网络访问的情况下缓冲并解析 SOAP XML。 2. 根据 Envelope 命名空间识别 SOAP 1.1 或 SOAP 1.2,并按照 `soap.version` 验证版本。 -3. 保留已有的 SOAP Header;若不存在,则在 Body 前创建。 -4. 创建或复用 `wsse:Security`,并设置 SOAP `mustUnderstand` 属性。 +3. 保留已有的 SOAP Header(必须位于 Body 之前);若不存在,则在 Body 前创建。 +4. 创建或复用 `wsse:Security`,并设置 SOAP `mustUnderstand` 属性;被复用的标头不得已经包含 `wsu:Timestamp`。 5. 如果 Body 尚无 `wsu:Id`,则为其分配 ID。 6. 创建包含 `Created`、`Expires` 和唯一 `wsu:Id` 的 UTC Timestamp。 7. 除非 `signature.key_info` 为 `none`,否则在 `wsse:BinarySecurityToken` 中嵌入 X.509 证书。 @@ -240,7 +240,7 @@ curl http://127.0.0.1:9080/signed-soap \ | HTTP 状态码 | 消息 | 原因 | | --- | --- | --- | | `400` | `empty_body` | 被选中的请求没有请求体。 | -| `400` | `invalid_soap` | XML 解析失败、Body 缺失、存在重复 `wsu:Id`,或存在 DTD/实体声明。 | +| `400` | `invalid_soap` | XML 解析失败、Body 缺失或重复、Header 重复或位于 Body 之后、存在重复 `wsu:Id`、`wsse:Security` 标头已包含 `wsu:Timestamp`,或存在 DTD/实体声明。 | | `400` | `not_soap` | 根元素不是受支持的 SOAP Envelope,或与 `soap.version` 不匹配。 | | `400` | `already_signed` | 消息已包含 `ds:Signature`。 | | `413` | `body_too_large` | 请求体超过 `request.max_body_bytes`。 | diff --git a/t/admin/plugins.t b/t/admin/plugins.t index b15b64d6966a..ba8066829879 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -113,6 +113,8 @@ ai-aws-content-moderation ai-rate-limiting ai-aliyun-content-moderation ai-lakera-guard +soap-signer +xml-signer proxy-mirror graphql-proxy-cache proxy-rewrite diff --git a/t/plugin/soap-signer.t b/t/plugin/soap-signer.t index ddd191ab63ee..cb10ce17aef2 100644 --- a/t/plugin/soap-signer.t +++ b/t/plugin/soap-signer.t @@ -353,3 +353,180 @@ routes: [] GET /t --- response_body signed without KeyInfo + + + +=== TEST 6: reject malformed SOAP envelopes +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.soap-signer.signer") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local conf = { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + } + local function envelope(children) + return '' + .. children .. "" + end + + local bodies = { + envelope(""), + envelope(""), + envelope(""), + envelope(""), + ']>' .. envelope(""), + } + for _, body in ipairs(bodies) do + local _, code, err = signer.sign(body, conf, 1790856000) + ngx.say(code, ": ", err) + end + } + } +--- request +GET /t +--- response_body +invalid_soap: SOAP Header must precede SOAP Body +invalid_soap: exactly one SOAP Body is required +invalid_soap: at most one SOAP Header is allowed +invalid_soap: SOAP Body is required +invalid_soap: DTD and entity declarations are not allowed + + + +=== TEST 7: keep a single wsu:Timestamp in a reused Security header +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + local ns = signer.namespaces + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local conf = { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + } + local function envelope(security) + return string.format([[ + + %s + +]], ns.soap11, ns.wsse, ns.wsu, security) + end + + local _, code, err = signer.sign(envelope([[ + + 2026-10-05T00:00:00Z + 2026-10-05T00:05:00Z +]]), conf, 1790856000) + ngx.say(code, ": ", err) + + local signed = assert(signer.sign(envelope([[ +user]]), + conf, 1790856000)) + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + ngx.say(#xml.find_all(root, ns.wsse, "Security"), " ", + #xml.find_all(root, ns.wsse, "UsernameToken"), " ", + #xml.find_all(root, ns.wsu, "Timestamp"), " ", + #xml.find_all(root, ns.ds, "Signature")) + xml.free_document(doc) + } + } +--- request +GET /t +--- response_body +invalid_soap: wsse:Security already contains a wsu:Timestamp +1 1 1 1 + + + +=== TEST 8: canonicalize default-namespaced Body content as external verifiers do +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local digest = require("resty.openssl.digest") + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + local ns = signer.namespaces + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local signed = assert(signer.sign([[ + + 1x +]], { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + }, 1790856000)) + + local doc = assert(xml.parse(signed)) + local root = assert(xml.root(doc)) + local body = assert(xml.find_child(root, ns.soap11, "Body")) + local body_id = assert(xml.get_namespaced_property(body, ns.wsu, "Id")) + + -- Exclusive C14N of the Body, written out by hand. + local expected = '' + .. '1x' + .. "" + local context = assert(digest.new("sha256")) + assert(context:update(expected)) + local expected_digest = ngx.encode_base64(assert(context:final())) + + local actual_digest + for _, reference in ipairs(xml.find_all(root, ns.ds, "Reference")) do + if xml.get_property(reference, "URI") == "#" .. body_id then + actual_digest = xml.content(assert(xml.find_child( + reference, ns.ds, "DigestValue"))) + end + end + xml.free_document(doc) + ngx.say(actual_digest == expected_digest) + } + } +--- request +GET /t +--- response_body +true diff --git a/t/plugin/soap-signer2.t b/t/plugin/soap-signer2.t new file mode 100644 index 000000000000..ae46990e2406 --- /dev/null +++ b/t/plugin/soap-signer2.t @@ -0,0 +1,113 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +no_long_string(); +no_shuffle(); +no_root_location(); + +run_tests; + +__DATA__ + +=== TEST 1: sign proxied SOAP requests and leave other requests untouched +--- config + location /upstream { + content_by_lua_block { + local core = require("apisix.core") + local xml = require("apisix.plugins.signing.xml") + local signer = require("apisix.plugins.soap-signer.signer") + + local body = core.request.get_body() or "" + local signatures = 0 + local doc = xml.parse(body) + if doc then + signatures = #xml.find_all( + assert(xml.root(doc)), signer.namespaces.ds, "Signature") + xml.free_document(doc) + end + local length = tonumber(ngx.var.http_content_length) or 0 + ngx.say("signatures=", signatures, " length_ok=", length == #body) + } + } + location /t { + content_by_lua_block { + local t = require("lib.test_admin") + local core = require("apisix.core") + local http = require("resty.http") + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local code = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + core.json.encode({ + uri = "/soap", + plugins = { + ["proxy-rewrite"] = {uri = "/upstream"}, + ["soap-signer"] = { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + }, + }, + upstream = { + type = "roundrobin", + nodes = {["127.0.0.1:" .. ngx.var.server_port] = 1}, + }, + }) + ) + if code >= 300 then + ngx.status = code + return + end + ngx.sleep(0.5) + + local function call(method, content_type, body) + local res = assert(http.new():request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. "/soap", + {method = method, body = body, + headers = {["Content-Type"] = content_type}})) + if res.status == 200 then + ngx.print(res.status, " ", res.body) + else + ngx.say(res.status, " ", core.json.decode(res.body).message) + end + end + + call("POST", "text/xml", '' + .. '') + call("GET", "text/xml") + call("POST", "application/json", "{}") + call("POST", "text/xml", "= 300 then + ngx.status = code + return + end + ngx.sleep(0.5) + + local function call(method, content_type, body) + local res = assert(http.new():request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. "/xml", + {method = method, body = body, + headers = {["Content-Type"] = content_type}})) + if res.status == 200 then + ngx.print(res.status, " ", res.body) + else + ngx.say(res.status, " ", core.json.decode(res.body).message) + end + end + + call("POST", "application/xml", + '1') + call("GET", "application/xml") + call("POST", "application/json", "{}") + call("POST", "application/xml", " Date: Tue, 6 Oct 2026 08:36:21 +0300 Subject: [PATCH 6/8] Update the signer --- apisix/plugins/soap-signer/signer.lua | 28 +++++++++- t/plugin/soap-signer.t | 73 +++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/apisix/plugins/soap-signer/signer.lua b/apisix/plugins/soap-signer/signer.lua index d8acfcb425a8..ec6d0ae3ee5e 100644 --- a/apisix/plugins/soap-signer/signer.lua +++ b/apisix/plugins/soap-signer/signer.lua @@ -28,6 +28,7 @@ local encode_base64 = ngx.encode_base64 local SOAP11 = "http://schemas.xmlsoap.org/soap/envelope/" local SOAP12 = "http://www.w3.org/2003/05/soap-envelope" +local SOAP12_ULTIMATE_RECEIVER = SOAP12 .. "/role/ultimateReceiver" local WSSE = "http://docs.oasis-open.org/wss/2004/01/" .. "oasis-200401-wss-wssecurity-secext-1.0.xsd" local WSU = "http://docs.oasis-open.org/wss/2004/01/" @@ -109,6 +110,25 @@ local function find_envelope_parts(doc) end +local function find_security(header, soap_namespace) + local role_attribute = soap_namespace == SOAP11 and "actor" or "role" + local security + for child in xml.each_child(header) do + if xml.name(child) == "Security" and xml.namespace_uri(child) == WSSE then + local role = xml.get_namespaced_property(child, soap_namespace, role_attribute) + if role == nil or (soap_namespace == SOAP12 + and role == SOAP12_ULTIMATE_RECEIVER) then + if security then + return nil, "multiple wsse:Security headers target the ultimate receiver" + end + security = child + end + end + end + return security +end + + local function ensure_namespaces(envelope) envelope.soap_ns = xml.ensure_namespace( envelope.doc, envelope.root, "soapenv", envelope.soap_namespace) @@ -308,7 +328,13 @@ function _M.sign(body, conf, now) return nil, "invalid_soap", unique_err end - envelope.security = xml.find_child(envelope.header, WSSE, "Security") + local security_err + envelope.security, security_err = find_security(envelope.header, + envelope.soap_namespace) + if security_err then + xml.free_document(doc) + return nil, "invalid_soap", security_err + end if envelope.security and xml.find_child(envelope.security, WSU, "Timestamp") then xml.free_document(doc) return nil, "invalid_soap", "wsse:Security already contains a wsu:Timestamp" diff --git a/t/plugin/soap-signer.t b/t/plugin/soap-signer.t index cb10ce17aef2..76b13c44cdf5 100644 --- a/t/plugin/soap-signer.t +++ b/t/plugin/soap-signer.t @@ -74,9 +74,6 @@ routes: [] GET /t --- response_body true -false -true -false @@ -530,3 +527,73 @@ routes: [] GET /t --- response_body true + + + +=== TEST 9: select only an unambiguous ultimate-receiver Security header +--- apisix_yaml +routes: [] +#END +--- config + location /t { + content_by_lua_block { + local signer = require("apisix.plugins.soap-signer.signer") + local xml = require("apisix.plugins.signing.xml") + local ns = signer.namespaces + + local function read(path) + local file = assert(io.open(path, "rb")) + local value = file:read("*a") + file:close() + return value + end + + local conf = { + credentials = { + certificate = read("t/certs/server.crt"), + private_key = read("t/certs/server.key"), + }, + soap = {version = "auto", must_understand = true}, + timestamp = {ttl_seconds = 300}, + } + local soap11 = assert(signer.sign([[ + + ]], conf, 1790856000)) + local doc = assert(xml.parse(soap11)) + local root = assert(xml.root(doc)) + local security = xml.find_all(root, ns.wsse, "Security") + assert(#security == 2) + assert(#xml.find_all(security[1], ns.ds, "Signature") == 0) + assert(#xml.find_all(security[2], ns.ds, "Signature") == 1) + xml.free_document(doc) + + local soap12 = assert(signer.sign([[ + + + + ]], conf, 1790856000)) + doc = assert(xml.parse(soap12)) + root = assert(xml.root(doc)) + security = xml.find_all(root, ns.wsse, "Security") + assert(#security == 3) + assert(#xml.find_all(security[1], ns.ds, "Signature") == 0) + assert(#xml.find_all(security[2], ns.ds, "Signature") == 0) + assert(#xml.find_all(security[3], ns.ds, "Signature") == 1) + xml.free_document(doc) + + local _, code, err = signer.sign([[ + + ]], conf, 1790856000) + ngx.say(code, ": ", err) + } + } +--- request +GET /t +--- response_body +invalid_soap: multiple wsse:Security headers target the ultimate receiver From 3e7ab73aa769007fa0c201cc785e4e12087d016d Mon Sep 17 00:00:00 2001 From: kerem Date: Tue, 6 Oct 2026 08:59:11 +0300 Subject: [PATCH 7/8] Potential fix for pull request finding 'Include all four schema results in the expected test output' add missing test parameters Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- t/plugin/soap-signer.t | 3 +++ 1 file changed, 3 insertions(+) diff --git a/t/plugin/soap-signer.t b/t/plugin/soap-signer.t index 76b13c44cdf5..04c6f23748d8 100644 --- a/t/plugin/soap-signer.t +++ b/t/plugin/soap-signer.t @@ -74,6 +74,9 @@ routes: [] GET /t --- response_body true +false +true +false From 58fa57fc0cb4b51036f7b0ea2e230689ea0c3417 Mon Sep 17 00:00:00 2001 From: keremoge Date: Tue, 6 Oct 2026 15:44:32 +0300 Subject: [PATCH 8/8] fix copilot findings --- apisix/plugins/signing/request.lua | 59 ++++++++++++++++++++++++++- apisix/plugins/signing/xml.lua | 34 +++++++++++++++ apisix/plugins/soap-signer/signer.lua | 3 +- docs/en/latest/plugins/soap-signer.md | 13 ++++-- docs/en/latest/plugins/xml-signer.md | 8 ++-- docs/zh/latest/plugins/soap-signer.md | 10 +++-- docs/zh/latest/plugins/xml-signer.md | 8 ++-- t/plugin/soap-signer.t | 9 ++++ t/plugin/xml-signer.t | 25 ++++++++++-- 9 files changed, 147 insertions(+), 22 deletions(-) diff --git a/apisix/plugins/signing/request.lua b/apisix/plugins/signing/request.lua index 4fa76beaa3ce..deeb44e39034 100644 --- a/apisix/plugins/signing/request.lua +++ b/apisix/plugins/signing/request.lua @@ -23,6 +23,13 @@ local type = type local _M = {} +local BODY_DIGEST_HEADERS = { + "Content-MD5", + "Digest", + "Content-Digest", + "Repr-Digest", +} + local function contains(values, expected) for _, value in ipairs(values) do @@ -43,6 +50,48 @@ local function normalized_content_type(value) end +local function has_unsupported_charset(value) + if not value then + return false + end + + local parameter_start + local quoted = false + local escaped = false + + for index = 1, #value + 1 do + local char = value:sub(index, index) + if index > #value or (char == ";" and not quoted) then + if parameter_start then + local parameter = value:sub(parameter_start, index - 1) + local name, charset = parameter:match( + "^%s*([^=%s]+)%s*=%s*(.-)%s*$") + if name and name:lower() == "charset" then + charset = charset:match("^%s*(.-)%s*$") + if charset:sub(1, 1) == '"' and charset:sub(-1) == '"' then + charset = charset:sub(2, -2) + end + charset = charset:lower() + if charset ~= "utf-8" and charset ~= "utf8" then + return true + end + end + end + parameter_start = index + 1 + elseif char == '"' and not escaped then + quoted = not quoted + end + + if char == "\\" and quoted and not escaped then + escaped = true + else + escaped = false + end + end + return false +end + + function _M.rewrite(plugin_name, conf, ctx, sign) if type(conf) ~= "table" then return 500, {message = "internal_error"} @@ -57,11 +106,14 @@ function _M.rewrite(plugin_name, conf, ctx, sign) return end - local content_type = normalized_content_type( - core.request.header(ctx, "Content-Type")) + local content_type_header = core.request.header(ctx, "Content-Type") + local content_type = normalized_content_type(content_type_header) if not contains(conf.request.content_types, content_type) then return 415, {message = "unsupported_media_type"} end + if has_unsupported_charset(content_type_header) then + return 415, {message = "unsupported_media_type"} + end local body, body_err = core.request.get_body(conf.request.max_body_bytes, ctx) if not body then @@ -90,6 +142,9 @@ function _M.rewrite(plugin_name, conf, ctx, sign) ngx.req.set_body_data(signed) core.request.set_header(ctx, "Content-Length", tostring(#signed)) ngx.req.clear_header("Transfer-Encoding") + for _, header in ipairs(BODY_DIGEST_HEADERS) do + ngx.req.clear_header(header) + end end diff --git a/apisix/plugins/signing/xml.lua b/apisix/plugins/signing/xml.lua index f4c8dd4e7e57..176def968bf0 100644 --- a/apisix/plugins/signing/xml.lua +++ b/apisix/plugins/signing/xml.lua @@ -103,6 +103,7 @@ xmlAttrPtr xmlSetProp(xmlNodePtr node, const xmlChar *name, const xmlChar *value); xmlChar *xmlGetNsProp(const xmlNodePtr node, const xmlChar *name, const xmlChar *name_space); +xmlChar *xmlGetNoNsProp(const xmlNodePtr node, const xmlChar *name); xmlChar *xmlGetProp(const xmlNodePtr node, const xmlChar *name); xmlChar *xmlNodeGetContent(const xmlNodePtr cur); @@ -123,6 +124,9 @@ local XML_ELEMENT_NODE = 1 local XML_DTD_NODE = 14 local XML_PARSE_NONET = 2048 local EXCLUSIVE_C14N = 1 +local XML_ID_NAMESPACE = "http://www.w3.org/XML/1998/namespace" +local WSU_ID_NAMESPACE = "http://docs.oasis-open.org/wss/2004/01/" + .. "oasis-200401-wss-wssecurity-utility-1.0.xsd" local _M = {} @@ -302,6 +306,36 @@ function _M.get_property(node, name) end +function _M.id_values(node) + local values = {} + local attribute = node.properties + while attribute ~= nil do + local name = ffi.string(attribute.name) + local namespace_uri + if attribute.ns ~= nil and attribute.ns.href ~= nil then + namespace_uri = ffi.string(attribute.ns.href) + end + + if (name == "Id" and (namespace_uri == nil + or namespace_uri == WSU_ID_NAMESPACE)) + or (name == "id" and namespace_uri == XML_ID_NAMESPACE) then + local value + if namespace_uri then + value = copy_and_free(C.xmlGetNsProp( + node, xml_char(name), xml_char(namespace_uri))) + else + value = copy_and_free(C.xmlGetNoNsProp(node, xml_char(name))) + end + if value then + values[#values + 1] = value + end + end + attribute = attribute.next + end + return values +end + + function _M.content(node) return copy_and_free(C.xmlNodeGetContent(node)) end diff --git a/apisix/plugins/soap-signer/signer.lua b/apisix/plugins/soap-signer/signer.lua index ec6d0ae3ee5e..0dc4351108c4 100644 --- a/apisix/plugins/soap-signer/signer.lua +++ b/apisix/plugins/soap-signer/signer.lua @@ -162,8 +162,7 @@ local function reject_duplicate_ids(root) local seen = {} local duplicate xml.walk(root, function(node) - local id = get_id(node) - if id then + for _, id in ipairs(xml.id_values(node)) do if seen[id] then duplicate = id end diff --git a/docs/en/latest/plugins/soap-signer.md b/docs/en/latest/plugins/soap-signer.md index 16f45586ab29..8bc2c7b27974 100644 --- a/docs/en/latest/plugins/soap-signer.md +++ b/docs/en/latest/plugins/soap-signer.md @@ -62,7 +62,12 @@ rewrite phase: `signature.key_info` is `none`. 8. Calculates a digest for both the Body and Timestamp using the algorithm selected by `signature.algorithm`. -9. Signs canonicalized `ds:SignedInfo` and updates the upstream request body. +9. Signs canonicalized `ds:SignedInfo`, replaces the upstream request body, + updates `Content-Length`, and removes body-digest headers that no longer + describe the signed body. + +The body is serialized as UTF-8. Requests with an explicit non-UTF-8 `charset` +are rejected. The generated security header has this general structure: @@ -113,7 +118,7 @@ The generated security header has this general structure: | `soap.must_understand` | boolean | False | `true` | Value of the SOAP `mustUnderstand` attribute on the Security header. | | `timestamp.ttl_seconds` | integer | False | `300` | Timestamp lifetime. Valid range: 1 to 86400 seconds. | | `request.methods` | array[string] | False | `["POST"]` | Request methods to sign. Other methods pass through unchanged. | -| `request.content_types` | array[string] | False | `["text/xml", "application/soap+xml", "application/xml"]` | Accepted media types. | +| `request.content_types` | array[string] | False | `["text/xml", "application/soap+xml", "application/xml"]` | Accepted media types. An explicit non-UTF-8 `charset` is rejected. | | `request.max_body_bytes` | integer | False | `5242880` | Maximum request body size in bytes. Valid range: 1024 to 67108864. | NOTE: `encrypt_fields = {"credentials.private_key"}` is defined in the @@ -320,11 +325,11 @@ unchanged. | HTTP status | Message | Cause | | --- | --- | --- | | `400` | `empty_body` | The selected request has no body. | -| `400` | `invalid_soap` | XML parsing failed, Body is absent or duplicated, Header is duplicated or follows the Body, duplicate `wsu:Id` values exist, the `wsse:Security` header already contains a `wsu:Timestamp`, or a DTD/entity declaration is present. | +| `400` | `invalid_soap` | XML parsing failed, Body is absent or duplicated, Header is duplicated or follows the Body, recognized XML ID values (`wsu:Id`, `xml:id`, and unqualified `Id`) are duplicated, the `wsse:Security` header already contains a `wsu:Timestamp`, or a DTD/entity declaration is present. | | `400` | `not_soap` | The root is not a supported SOAP Envelope or does not match `soap.version`. | | `400` | `already_signed` | The message already contains a `ds:Signature`. | | `413` | `body_too_large` | The body exceeds `request.max_body_bytes`. | -| `415` | `unsupported_media_type` | The selected method uses a Content-Type not listed in `request.content_types`. | +| `415` | `unsupported_media_type` | The selected method uses a Content-Type not listed in `request.content_types` or declares a non-UTF-8 charset. | | `500` | `credential_error` | A Secret could not be resolved, PEM parsing failed, the key is not RSA, or the certificate and key do not match. | | `500` | `signing_error` | ID generation, canonicalization, digest generation, signing, or serialization failed. | diff --git a/docs/en/latest/plugins/xml-signer.md b/docs/en/latest/plugins/xml-signer.md index 1484fdb8e2ea..d446b40e29ac 100644 --- a/docs/en/latest/plugins/xml-signer.md +++ b/docs/en/latest/plugins/xml-signer.md @@ -59,10 +59,12 @@ rewrite phase: 6. Creates an enveloped `ds:Signature` under the document root. 7. Adds X.509 `ds:KeyInfo` unless `signature.key_info` is `none`. 8. Canonicalizes and signs `ds:SignedInfo` with the configured RSA private key. -9. Replaces the upstream request body and updates `Content-Length`. +9. Replaces the upstream request body, updates `Content-Length`, and removes + body-digest headers that no longer describe the signed body. The body is serialized as UTF-8 after signing. Insignificant formatting such as indentation or quote style can therefore differ from the original request. +Requests with an explicit non-UTF-8 `charset` are rejected. The generated signature has the following structure: @@ -111,7 +113,7 @@ The generated signature has the following structure: | `signature.algorithm` | string | False | `rsa-sha256` | Signature and reference digest algorithm. Valid values are `rsa-sha256` and `rsa-sha1`. | | `signature.key_info` | string | False | `x509_data` | Key information included in the signature. Use `x509_data` to embed the certificate or `none` to omit `ds:KeyInfo`. | | `request.methods` | array[string] | False | `["POST"]` | Request methods to sign. Other methods pass through unchanged. | -| `request.content_types` | array[string] | False | `["application/xml", "text/xml"]` | Accepted media types. Parameters such as `charset` are ignored. | +| `request.content_types` | array[string] | False | `["application/xml", "text/xml"]` | Accepted media types. Parameters are ignored except that an explicit non-UTF-8 `charset` is rejected. | | `request.max_body_bytes` | integer | False | `5242880` | Maximum request body size in bytes. Valid range: 1024 to 67108864. | NOTE: `encrypt_fields = {"credentials.private_key"}` is defined in the @@ -302,7 +304,7 @@ through unchanged. | `400` | `invalid_xml` | XML parsing failed, the document has no root, or a DTD/entity declaration is present. | | `400` | `already_signed` | The document already contains a `ds:Signature`. | | `413` | `body_too_large` | The body exceeds `request.max_body_bytes`. | -| `415` | `unsupported_media_type` | The selected method uses a Content-Type not listed in `request.content_types`. | +| `415` | `unsupported_media_type` | The selected method uses a Content-Type not listed in `request.content_types` or declares a non-UTF-8 charset. | | `500` | `credential_error` | A Secret could not be resolved, PEM parsing failed, the key is not RSA, or the certificate and key do not match. | | `500` | `signing_error` | Canonicalization, digest generation, signing, or serialization failed. | diff --git a/docs/zh/latest/plugins/soap-signer.md b/docs/zh/latest/plugins/soap-signer.md index a3f22c210082..b77b993b58a3 100644 --- a/docs/zh/latest/plugins/soap-signer.md +++ b/docs/zh/latest/plugins/soap-signer.md @@ -48,7 +48,9 @@ description: soap-signer 插件使用 X.509 WS-Security 签名为 SOAP 请求体 6. 创建包含 `Created`、`Expires` 和唯一 `wsu:Id` 的 UTC Timestamp。 7. 除非 `signature.key_info` 为 `none`,否则在 `wsse:BinarySecurityToken` 中嵌入 X.509 证书。 8. 使用 `signature.algorithm` 指定的算法分别计算 Body 和 Timestamp 的摘要。 -9. 对 canonicalized `ds:SignedInfo` 签名,并更新发往上游的请求体。 +9. 对 canonicalized `ds:SignedInfo` 签名,替换发往上游的请求体、更新 `Content-Length`,并移除不再对应签名后请求体的摘要标头。 + +请求体会以 UTF-8 序列化。显式声明非 UTF-8 `charset` 的请求会被拒绝。 生成的安全标头大致如下: @@ -95,7 +97,7 @@ description: soap-signer 插件使用 X.509 WS-Security 签名为 SOAP 请求体 | `soap.must_understand` | boolean | 否 | `true` | Security 标头中 SOAP `mustUnderstand` 属性的值。 | | `timestamp.ttl_seconds` | integer | 否 | `300` | Timestamp 有效期,有效范围为 1 到 86400 秒。 | | `request.methods` | array[string] | 否 | `["POST"]` | 要签名的请求方法。其他方法的请求保持不变并直接转发。 | -| `request.content_types` | array[string] | 否 | `["text/xml", "application/soap+xml", "application/xml"]` | 接受的媒体类型。 | +| `request.content_types` | array[string] | 否 | `["text/xml", "application/soap+xml", "application/xml"]` | 接受的媒体类型;显式声明非 UTF-8 `charset` 的请求会被拒绝。 | | `request.max_body_bytes` | integer | 否 | `5242880` | 请求体最大字节数,有效范围为 1024 到 67108864。 | 注意:schema 中定义了 `encrypt_fields = {"credentials.private_key"}`,因此直接配置的私钥会在 etcd 中加密。建议使用 [APISIX Secret](../terminology/secret.md) 引用,避免密钥材料保存在路由配置中。 @@ -240,11 +242,11 @@ curl http://127.0.0.1:9080/signed-soap \ | HTTP 状态码 | 消息 | 原因 | | --- | --- | --- | | `400` | `empty_body` | 被选中的请求没有请求体。 | -| `400` | `invalid_soap` | XML 解析失败、Body 缺失或重复、Header 重复或位于 Body 之后、存在重复 `wsu:Id`、`wsse:Security` 标头已包含 `wsu:Timestamp`,或存在 DTD/实体声明。 | +| `400` | `invalid_soap` | XML 解析失败、Body 缺失或重复、Header 重复或位于 Body 之后、XML ID(`wsu:Id`、`xml:id` 和无命名空间的 `Id`)重复、`wsse:Security` 标头已包含 `wsu:Timestamp`,或存在 DTD/实体声明。 | | `400` | `not_soap` | 根元素不是受支持的 SOAP Envelope,或与 `soap.version` 不匹配。 | | `400` | `already_signed` | 消息已包含 `ds:Signature`。 | | `413` | `body_too_large` | 请求体超过 `request.max_body_bytes`。 | -| `415` | `unsupported_media_type` | 请求方法匹配,但 Content-Type 未列入 `request.content_types`。 | +| `415` | `unsupported_media_type` | 请求方法匹配,但 Content-Type 未列入 `request.content_types`,或声明了非 UTF-8 字符集。 | | `500` | `credential_error` | Secret 解析失败、PEM 解析失败、密钥不是 RSA,或证书与私钥不匹配。 | | `500` | `signing_error` | ID 生成、canonicalization、摘要生成、签名或序列化失败。 | diff --git a/docs/zh/latest/plugins/xml-signer.md b/docs/zh/latest/plugins/xml-signer.md index 30eff52895c6..a4387ca01457 100644 --- a/docs/zh/latest/plugins/xml-signer.md +++ b/docs/zh/latest/plugins/xml-signer.md @@ -48,9 +48,9 @@ description: xml-signer 插件为 XML 请求体添加 enveloped XML 数字签名 6. 在文档根元素下创建 enveloped `ds:Signature`。 7. 除非 `signature.key_info` 为 `none`,否则添加 X.509 `ds:KeyInfo`。 8. 对 `ds:SignedInfo` 执行 canonicalization,并使用配置的 RSA 私钥签名。 -9. 替换发往上游的请求体并更新 `Content-Length`。 +9. 替换发往上游的请求体、更新 `Content-Length`,并移除不再对应签名后请求体的摘要标头。 -签名后的请求体会以 UTF-8 序列化,因此缩进、引号风格等无关格式可能与原请求不同。 +签名后的请求体会以 UTF-8 序列化,因此缩进、引号风格等无关格式可能与原请求不同。显式声明非 UTF-8 `charset` 的请求会被拒绝。 生成的签名结构如下: @@ -96,7 +96,7 @@ description: xml-signer 插件为 XML 请求体添加 enveloped XML 数字签名 | `signature.algorithm` | string | 否 | `rsa-sha256` | 签名和引用摘要算法。可选值为 `rsa-sha256` 和 `rsa-sha1`。 | | `signature.key_info` | string | 否 | `x509_data` | 签名中包含的密钥信息。使用 `x509_data` 嵌入证书,或使用 `none` 省略 `ds:KeyInfo`。 | | `request.methods` | array[string] | 否 | `["POST"]` | 要签名的请求方法。其他方法的请求保持不变并直接转发。 | -| `request.content_types` | array[string] | 否 | `["application/xml", "text/xml"]` | 接受的媒体类型;会忽略 `charset` 等参数。 | +| `request.content_types` | array[string] | 否 | `["application/xml", "text/xml"]` | 接受的媒体类型;会忽略其他参数,但会拒绝显式声明非 UTF-8 `charset` 的请求。 | | `request.max_body_bytes` | integer | 否 | `5242880` | 请求体最大字节数,有效范围为 1024 到 67108864。 | 注意:schema 中定义了 `encrypt_fields = {"credentials.private_key"}`,因此直接配置的私钥会在 etcd 中加密。建议使用 [APISIX Secret](../terminology/secret.md) 引用,避免密钥材料保存在路由配置中。 @@ -223,7 +223,7 @@ curl http://127.0.0.1:9080/signed-xml \ | `400` | `invalid_xml` | XML 解析失败、文档没有根元素,或存在 DTD/实体声明。 | | `400` | `already_signed` | 文档已包含 `ds:Signature`。 | | `413` | `body_too_large` | 请求体超过 `request.max_body_bytes`。 | -| `415` | `unsupported_media_type` | 请求方法匹配,但 Content-Type 未列入 `request.content_types`。 | +| `415` | `unsupported_media_type` | 请求方法匹配,但 Content-Type 未列入 `request.content_types`,或声明了非 UTF-8 字符集。 | | `500` | `credential_error` | Secret 解析失败、PEM 解析失败、密钥不是 RSA,或证书与私钥不匹配。 | | `500` | `signing_error` | canonicalization、摘要生成、签名或序列化失败。 | diff --git a/t/plugin/soap-signer.t b/t/plugin/soap-signer.t index 04c6f23748d8..f372d2cf340d 100644 --- a/t/plugin/soap-signer.t +++ b/t/plugin/soap-signer.t @@ -391,6 +391,13 @@ routes: [] envelope(""), envelope(""), ']>' .. envelope(""), + envelope('' + .. ""), + envelope(''), } for _, body in ipairs(bodies) do local _, code, err = signer.sign(body, conf, 1790856000) @@ -406,6 +413,8 @@ invalid_soap: exactly one SOAP Body is required invalid_soap: at most one SOAP Header is allowed invalid_soap: SOAP Body is required invalid_soap: DTD and entity declarations are not allowed +invalid_soap: duplicate XML ID +invalid_soap: duplicate XML ID diff --git a/t/plugin/xml-signer.t b/t/plugin/xml-signer.t index 794da4f4033f..892f74f7f354 100644 --- a/t/plugin/xml-signer.t +++ b/t/plugin/xml-signer.t @@ -352,7 +352,7 @@ routes: [] local body = "" local body_err local rewritten_body - local cleared_header + local cleared_headers = {} ngx.req.get_method = function() return method @@ -361,7 +361,7 @@ routes: [] rewritten_body = value end ngx.req.clear_header = function(name) - cleared_header = name + cleared_headers[name] = true end core.request.header = function(_, name) assert(name == "Content-Type") @@ -394,7 +394,12 @@ routes: [] assert(sign_calls == 1) assert(rewritten_body == "-signed") assert(ctx["Content-Length"] == tostring(#rewritten_body)) - assert(cleared_header == "Transfer-Encoding") + assert(cleared_headers["Transfer-Encoding"]) + for _, name in ipairs({ + "Content-MD5", "Digest", "Content-Digest", "Repr-Digest", + }) do + assert(cleared_headers[name]) + end method = "GET" assert(request.rewrite("xml-signer", conf, {}, sign) == nil) @@ -408,6 +413,20 @@ routes: [] assert(response.message == "unsupported_media_type") assert(sign_calls == 1) + content_type = "application/xml; charset=iso-8859-1" + status, response = request.rewrite( + "xml-signer", conf, {}, sign) + assert(status == 415) + assert(response.message == "unsupported_media_type") + assert(sign_calls == 1) + + content_type = "application/xml; charset=utf-8; charset=iso-8859-1" + status, response = request.rewrite( + "xml-signer", conf, {}, sign) + assert(status == 415) + assert(response.message == "unsupported_media_type") + assert(sign_calls == 1) + content_type = "application/xml" body = nil body_err = "request size 2048 is greater than the maximum size 1024 allowed"