diff --git a/Makefile b/Makefile
index bcbb1e6bb808..3c13775654f1 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 a35ffa5b290b..c16494575c76 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -256,6 +256,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/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..70047abdbce9
--- /dev/null
+++ b/apisix/plugins/signing/credentials.lua
@@ -0,0 +1,103 @@
+--
+-- 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 tostring = tostring
+local type = type
+
+
+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..deeb44e39034
--- /dev/null
+++ b/apisix/plugins/signing/request.lua
@@ -0,0 +1,151 @@
+--
+-- 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 ipairs = ipairs
+local ngx = ngx
+local tostring = tostring
+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
+ 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
+
+
+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"}
+ 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_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
+ 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
+ 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")
+ for _, header in ipairs(BODY_DIGEST_HEADERS) do
+ ngx.req.clear_header(header)
+ end
+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..176def968bf0
--- /dev/null
+++ b/apisix/plugins/signing/xml.lua
@@ -0,0 +1,398 @@
+--
+-- 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")
+
+local type = type
+
+
+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);
+
+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 *xmlGetNoNsProp(const xmlNodePtr node, const xmlChar *name);
+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_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 = {}
+
+
+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 doc = C.xmlReadMemory(value, #value, "document.xml", nil, XML_PARSE_NONET)
+ if doc == nil then
+ return nil, "failed to parse XML"
+ end
+
+ local child = ffi.cast("xmlNodePtr", doc).children
+ while child ~= nil do
+ if child.type == XML_DTD_NODE then
+ C.xmlFreeDoc(doc)
+ return nil, "DTD and entity declarations are not allowed"
+ end
+ child = child.next
+ end
+
+ ffi.gc(doc, C.xmlFreeDoc)
+ return doc
+end
+
+
+function _M.free_document(doc)
+ if doc ~= nil then
+ ffi.gc(doc, nil)
+ C.xmlFreeDoc(doc)
+ end
+end
+
+
+function _M.root(doc)
+ return C.xmlDocGetRootElement(doc)
+end
+
+
+function _M.name(node)
+ if node == nil or node.name == nil then
+ return nil
+ end
+ return ffi.string(node.name)
+end
+
+
+function _M.namespace_uri(node)
+ if node == nil or node.ns == nil or node.ns.href == nil then
+ return ""
+ end
+ return ffi.string(node.ns.href)
+end
+
+
+function _M.each_child(parent)
+ local current = parent ~= nil and parent.children or nil
+ return function()
+ while current ~= nil do
+ local node = current
+ current = current.next
+ if node.type == XML_ELEMENT_NODE then
+ return node
+ end
+ end
+ end
+end
+
+
+function _M.walk(root, visit)
+ visit(root)
+ for child in _M.each_child(root) do
+ _M.walk(child, visit)
+ end
+end
+
+
+function _M.find_child(parent, namespace_uri, local_name)
+ for child in _M.each_child(parent) do
+ if _M.name(child) == local_name and _M.namespace_uri(child) == namespace_uri then
+ return child
+ end
+ end
+end
+
+
+function _M.find_all(root, namespace_uri, local_name)
+ local result = {}
+ _M.walk(root, function(node)
+ if _M.name(node) == local_name and _M.namespace_uri(node) == namespace_uri then
+ result[#result + 1] = node
+ end
+ end)
+ return result
+end
+
+
+function _M.ensure_namespace(doc, node, prefix, href)
+ local by_href = C.xmlSearchNsByHref(doc, node, xml_char(href))
+ if by_href ~= nil and by_href.prefix ~= nil then
+ return by_href
+ end
+
+ local suffix = 0
+ while true do
+ local candidate = suffix == 0 and prefix or prefix .. suffix
+ local existing = C.xmlSearchNs(doc, node, xml_char(candidate))
+ if existing == nil then
+ return C.xmlNewNs(node, xml_char(href), xml_char(candidate))
+ end
+ if existing.href ~= nil and ffi.string(existing.href) == href then
+ return existing
+ end
+ suffix = suffix + 1
+ end
+end
+
+
+function _M.new_child(parent, namespace, local_name, content)
+ return C.xmlNewChild(parent, namespace, xml_char(local_name), xml_char(content))
+end
+
+
+function _M.new_node(namespace, local_name)
+ return C.xmlNewNode(namespace, xml_char(local_name))
+end
+
+
+function _M.add_before(existing, child)
+ return C.xmlAddPrevSibling(existing, child)
+end
+
+
+function _M.remove(node)
+ C.xmlUnlinkNode(node)
+ C.xmlFreeNode(node)
+end
+
+
+function _M.set_namespaced_property(node, namespace, name, value)
+ return C.xmlSetNsProp(node, namespace, xml_char(name), xml_char(value))
+end
+
+
+function _M.set_property(node, name, value)
+ return C.xmlSetProp(node, xml_char(name), xml_char(value))
+end
+
+
+function _M.get_namespaced_property(node, namespace_uri, name)
+ return copy_and_free(C.xmlGetNsProp(node, xml_char(name), xml_char(namespace_uri)))
+end
+
+
+function _M.get_property(node, name)
+ return copy_and_free(C.xmlGetProp(node, xml_char(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
+
+
+local function canonicalize_document(doc)
+ local output = ffi.new("xmlChar *[1]")
+ local length = C.xmlC14NDocDumpMemory(doc, nil, EXCLUSIVE_C14N, nil, 0, output)
+ if length < 0 or output[0] == nil then
+ if output[0] ~= nil then
+ C.xmlFree(output[0])
+ end
+ return nil, "canonicalization failed"
+ end
+
+ local result = ffi.string(output[0], length)
+ C.xmlFree(output[0])
+ return result
+end
+
+
+function _M.canonicalize_document(doc)
+ return canonicalize_document(doc)
+end
+
+
+function _M.canonicalize(node)
+ local doc = C.xmlNewDoc(xml_char("1.0"))
+ if doc == nil then
+ return nil, "failed to create canonicalization document"
+ end
+
+ local copy = C.xmlDocCopyNode(node, doc, 1)
+ if copy == nil then
+ C.xmlFreeDoc(doc)
+ return nil, "failed to copy canonicalization node"
+ end
+ C.xmlDocSetRootElement(doc, copy)
+
+ local result, err = canonicalize_document(doc)
+ C.xmlFreeDoc(doc)
+ return result, err
+end
+
+
+function _M.serialize(doc)
+ local output = ffi.new("xmlChar *[1]")
+ local length = ffi.new("int[1]")
+ C.xmlDocDumpMemoryEnc(doc, output, length, "UTF-8")
+ if output[0] == nil then
+ return nil, "XML serialization failed"
+ end
+
+ local result = ffi.string(output[0], length[0])
+ C.xmlFree(output[0])
+ return result
+end
+
+
+return _M
\ No newline at end of file
diff --git a/apisix/plugins/soap-signer.lua b/apisix/plugins/soap-signer.lua
new file mode 100644
index 000000000000..100491f3f363
--- /dev/null
+++ b/apisix/plugins/soap-signer.lua
@@ -0,0 +1,156 @@
+--
+-- 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.soap-signer.signer")
+
+local plugin_name = "soap-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 = {"binary_security_token", "none"},
+ default = "binary_security_token",
+ },
+ },
+ },
+ soap = {
+ type = "object",
+ additionalProperties = false,
+ default = {},
+ properties = {
+ version = {
+ type = "string",
+ enum = {"auto", "1.1", "1.2"},
+ default = "auto",
+ },
+ must_understand = {type = "boolean", default = true},
+ },
+ },
+ timestamp = {
+ type = "object",
+ additionalProperties = false,
+ default = {},
+ properties = {
+ ttl_seconds = {
+ type = "integer",
+ minimum = 1,
+ maximum = 86400,
+ default = 300,
+ },
+ },
+ },
+ 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 = {"text/xml", "application/soap+xml", "application/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 = 1027,
+ 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", "binary_security_token")
+ conf.soap = conf.soap or {}
+ set_default(conf.soap, "version", "auto")
+ set_default(conf.soap, "must_understand", true)
+ conf.timestamp = conf.timestamp or {}
+ set_default(conf.timestamp, "ttl_seconds", 300)
+ conf.request = conf.request or {}
+ set_default(conf.request, "methods", {"POST"})
+ set_default(conf.request, "content_types",
+ {"text/xml", "application/soap+xml", "application/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/soap-signer/signer.lua b/apisix/plugins/soap-signer/signer.lua
new file mode 100644
index 000000000000..0dc4351108c4
--- /dev/null
+++ b/apisix/plugins/soap-signer/signer.lua
@@ -0,0 +1,388 @@
+--
+-- 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 random = require("resty.random")
+local resty_string = require("resty.string")
+
+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/"
+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/"
+ .. "oasis-200401-wss-wssecurity-utility-1.0.xsd"
+local DS = "http://www.w3.org/2000/09/xmldsig#"
+local EXCLUSIVE_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#"
+local BASE64_ENCODING = "http://docs.oasis-open.org/wss/2004/01/"
+ .. "oasis-200401-wss-soap-message-security-1.0#Base64Binary"
+local X509_V3 = "http://docs.oasis-open.org/wss/2004/01/"
+ .. "oasis-200401-wss-x509-token-profile-1.0#X509v3"
+
+local _M = {
+ namespaces = {
+ soap11 = SOAP11,
+ soap12 = SOAP12,
+ wsse = WSSE,
+ wsu = WSU,
+ ds = DS,
+ },
+}
+
+
+local function new_id(prefix)
+ local value, err = random.bytes(16, true)
+ if not value then
+ return nil, err
+ end
+ return prefix .. "-" .. resty_string.to_hex(value)
+end
+
+
+local function find_envelope_parts(doc)
+ local root = xml.root(doc)
+ if not root or xml.name(root) ~= "Envelope" then
+ return nil, "not_soap", "root element is not SOAP Envelope"
+ end
+
+ local soap_namespace = xml.namespace_uri(root)
+ if soap_namespace ~= SOAP11 and soap_namespace ~= SOAP12 then
+ return nil, "not_soap", "unsupported SOAP namespace"
+ end
+
+ local body, header
+ for child in xml.each_child(root) do
+ if xml.namespace_uri(child) == soap_namespace then
+ local name = xml.name(child)
+ if name == "Body" then
+ if body then
+ return nil, "invalid_soap", "exactly one SOAP Body is required"
+ end
+ body = child
+ elseif name == "Header" then
+ 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
+ end
+ if not body then
+ return nil, "invalid_soap", "SOAP Body is required"
+ end
+
+ if not header then
+ header = xml.new_node(root.ns, "Header")
+ xml.add_before(body, header)
+ end
+
+ return {
+ doc = doc,
+ root = root,
+ header = header,
+ body = body,
+ soap_namespace = soap_namespace,
+ }
+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)
+ envelope.wsse_ns = xml.ensure_namespace(envelope.doc, envelope.root, "wsse", WSSE)
+ envelope.wsu_ns = xml.ensure_namespace(envelope.doc, envelope.root, "wsu", WSU)
+ envelope.ds_ns = xml.ensure_namespace(envelope.doc, envelope.root, "ds", DS)
+end
+
+
+local function get_id(node)
+ return xml.get_namespaced_property(node, WSU, "Id")
+end
+
+
+local function ensure_id(node, namespace, prefix)
+ local existing = get_id(node)
+ if existing and existing ~= "" then
+ return existing
+ end
+
+ local id, err = new_id(prefix)
+ if not id then
+ return nil, err
+ end
+ xml.set_namespaced_property(node, namespace, "Id", id)
+ return id
+end
+
+
+local function reject_duplicate_ids(root)
+ local seen = {}
+ local duplicate
+ xml.walk(root, function(node)
+ for _, id in ipairs(xml.id_values(node)) do
+ if seen[id] then
+ duplicate = id
+ end
+ seen[id] = true
+ end
+ end)
+ if duplicate then
+ return nil, "duplicate XML ID"
+ end
+ return true
+end
+
+
+local function timestamp_value(timestamp)
+ return os.date("!%Y-%m-%dT%H:%M:%SZ", timestamp)
+end
+
+
+local function build_timestamp(envelope, ttl_seconds, now)
+ local timestamp = xml.new_child(envelope.security, envelope.wsu_ns, "Timestamp")
+ local id, err = ensure_id(timestamp, envelope.wsu_ns, "TS")
+ if not id then
+ return nil, err
+ end
+ xml.new_child(timestamp, envelope.wsu_ns, "Created", timestamp_value(now))
+ xml.new_child(timestamp, envelope.wsu_ns, "Expires",
+ timestamp_value(now + ttl_seconds))
+ return timestamp
+end
+
+
+local function build_token(envelope, runtime, key_info_mode)
+ if key_info_mode == "none" then
+ return
+ end
+
+ local token_id, err = new_id("X509")
+ if not token_id then
+ return nil, err
+ end
+
+ local token = xml.new_child(envelope.security, envelope.wsse_ns,
+ "BinarySecurityToken",
+ encode_base64(runtime.certificate_der))
+ xml.set_namespaced_property(token, envelope.wsu_ns, "Id", token_id)
+ xml.set_property(token, "EncodingType", BASE64_ENCODING)
+ xml.set_property(token, "ValueType", X509_V3)
+ return token_id
+end
+
+
+local function add_reference(envelope, signed_info, target, algorithm)
+ local canonical, canonical_err = xml.canonicalize(target)
+ if not canonical then
+ return nil, canonical_err
+ end
+ local digest_value, digest_err = algorithms.hash(canonical, algorithm)
+ if not digest_value then
+ return nil, digest_err
+ end
+
+ local reference = xml.new_child(signed_info, envelope.ds_ns, "Reference")
+ xml.set_property(reference, "URI", "#" .. get_id(target))
+ local transforms = xml.new_child(reference, envelope.ds_ns, "Transforms")
+ local transform = xml.new_child(transforms, envelope.ds_ns, "Transform")
+ xml.set_property(transform, "Algorithm", EXCLUSIVE_C14N)
+ local digest_method = xml.new_child(reference, envelope.ds_ns, "DigestMethod")
+ xml.set_property(digest_method, "Algorithm", algorithm.digest_uri)
+ xml.new_child(reference, envelope.ds_ns, "DigestValue", encode_base64(digest_value))
+ return true
+end
+
+
+local function build_signature(envelope, runtime, token_id, targets, algorithm,
+ key_info_mode)
+ local signature = xml.new_child(envelope.security, envelope.ds_ns, "Signature")
+ local signed_info = xml.new_child(signature, envelope.ds_ns, "SignedInfo")
+
+ local canonicalization_method = xml.new_child(
+ signed_info, envelope.ds_ns, "CanonicalizationMethod")
+ xml.set_property(canonicalization_method, "Algorithm", EXCLUSIVE_C14N)
+ local signature_method = xml.new_child(signed_info, envelope.ds_ns, "SignatureMethod")
+ xml.set_property(signature_method, "Algorithm", algorithm.signature_uri)
+
+ for _, target in ipairs(targets) do
+ local ok, err = add_reference(envelope, signed_info, target, algorithm)
+ if not ok then
+ return nil, err
+ end
+ end
+
+ 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, envelope.ds_ns, "SignatureValue",
+ encode_base64(signature_value))
+
+ if key_info_mode == "binary_security_token" then
+ local key_info = xml.new_child(signature, envelope.ds_ns, "KeyInfo")
+ local token_reference = xml.new_child(key_info, envelope.wsse_ns,
+ "SecurityTokenReference")
+ local reference = xml.new_child(token_reference, envelope.wsse_ns, "Reference")
+ xml.set_property(reference, "URI", "#" .. token_id)
+ xml.set_property(reference, "ValueType", X509_V3)
+ end
+ return signature
+end
+
+
+local function set_must_understand(envelope, enabled)
+ local value = enabled and "1" or "0"
+ if envelope.soap_namespace == SOAP12 then
+ value = enabled and "true" or "false"
+ end
+ xml.set_namespaced_property(envelope.security, envelope.soap_ns,
+ "mustUnderstand", value)
+end
+
+
+function _M.sign(body, conf, now)
+ 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 "binary_security_token"
+ if key_info_mode ~= "binary_security_token" 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_soap", parse_err
+ end
+
+ local envelope, code, envelope_err = find_envelope_parts(doc)
+ if not envelope then
+ xml.free_document(doc)
+ return nil, code, envelope_err
+ end
+
+ local actual_version = envelope.soap_namespace == SOAP12 and "1.2" or "1.1"
+ if conf.soap.version ~= "auto" and conf.soap.version ~= actual_version then
+ xml.free_document(doc)
+ return nil, "not_soap", "SOAP version does not match configuration"
+ end
+ if #xml.find_all(envelope.root, DS, "Signature") > 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 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"
+ 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)
+ 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 d07a8d0563b9..6ddbc06b1d72 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -602,6 +602,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 e56d307599b0..a4da84e20455 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/request-decompress",
"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..8bc2c7b27974
--- /dev/null
+++ b/docs/en/latest/plugins/soap-signer.md
@@ -0,0 +1,365 @@
+---
+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, which must precede the Body, or creates
+ one before the Body.
+4. Creates or reuses `wsse:Security` and sets the SOAP `mustUnderstand`
+ 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
+ `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`, 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:
+
+```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. 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
+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 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` 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. |
+
+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..d446b40e29ac
--- /dev/null
+++ b/docs/en/latest/plugins/xml-signer.md
@@ -0,0 +1,338 @@
+---
+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, 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:
+
+```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 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
+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` 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. |
+
+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..b77b993b58a3
--- /dev/null
+++ b/docs/zh/latest/plugins/soap-signer.md
@@ -0,0 +1,272 @@
+---
+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 之前);若不存在,则在 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 证书。
+8. 使用 `signature.algorithm` 指定的算法分别计算 Body 和 Timestamp 的摘要。
+9. 对 canonicalized `ds:SignedInfo` 签名,替换发往上游的请求体、更新 `Content-Length`,并移除不再对应签名后请求体的摘要标头。
+
+请求体会以 UTF-8 序列化。显式声明非 UTF-8 `charset` 的请求会被拒绝。
+
+生成的安全标头大致如下:
+
+```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"]` | 接受的媒体类型;显式声明非 UTF-8 `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` | 仅用于兼容旧版 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 缺失或重复、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`,或声明了非 UTF-8 字符集。 |
+| `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..a4387ca01457
--- /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 序列化,因此缩进、引号风格等无关格式可能与原请求不同。显式声明非 UTF-8 `charset` 的请求会被拒绝。
+
+生成的签名结构如下:
+
+```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"]` | 接受的媒体类型;会忽略其他参数,但会拒绝显式声明非 UTF-8 `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`,或声明了非 UTF-8 字符集。 |
+| `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/admin/plugins.t b/t/admin/plugins.t
index 84eb30be056a..ce99fec6b696 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -114,6 +114,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
new file mode 100644
index 000000000000..f372d2cf340d
--- /dev/null
+++ b/t/plugin/soap-signer.t
@@ -0,0 +1,611 @@
+#
+# 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
+
+
+
+=== 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(""),
+ 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
+invalid_soap: duplicate XML ID
+invalid_soap: duplicate XML ID
+
+
+
+=== 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([[
+
+ - 1
x
+]], {
+ 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 = ''
+ .. '- 1
x'
+ .. ""
+ 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
+
+
+
+=== 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
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", "'
+ .. '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_headers = {}
+
+ 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_headers[name] = true
+ 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_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)
+ 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; 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"
+ status, response = request.rewrite("xml-signer", conf, {}, sign)
+ assert(status == 413)
+ assert(response.message == "body_too_large")
+ assert(sign_calls == 1)
+
+ body_err = "failed to read request body"
+ status, response = request.rewrite("xml-signer", conf, {}, sign)
+ assert(status == 500)
+ assert(response.message == "internal_error")
+ assert(sign_calls == 1)
+
+ body_err = nil
+ status, response = request.rewrite("xml-signer", conf, {}, sign)
+ assert(status == 400)
+ assert(response.message == "empty_body")
+ 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
+--- error_log
+xml-signer failed to read request body: request size 2048 is greater than the maximum size 1024 allowed
+xml-signer failed to read request body: failed to read request body
+xml-signer failed to read request body: nil
diff --git a/t/plugin/xml-signer2.t b/t/plugin/xml-signer2.t
new file mode 100644
index 000000000000..9a60a7371395
--- /dev/null
+++ b/t/plugin/xml-signer2.t
@@ -0,0 +1,112 @@
+#
+# 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 XML 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.xml-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 = "/xml",
+ plugins = {
+ ["proxy-rewrite"] = {uri = "/upstream"},
+ ["xml-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 .. "/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", "