keremoge opened a new pull request, #14175: URL: https://github.com/apache/apisix/pull/14175
### Description This PR adds a new plugin, `xml-signer`, which adds an enveloped [XML Digital Signature](https://www.w3.org/TR/xmldsig-core1/) to XML request bodies before APISIX proxies them upstream. #### Motivation Many B2B, government, banking and healthcare APIs require XML payloads to be signed with an X.509 certificate. Today, every client has to implement XML-DSig itself and hold the signing key. With this plugin, signing moves to the gateway: - the private key lives in one place: APISIX, ideally through an APISIX Secret reference; - clients send plain XML and are authenticated by an APISIX auth plugin; - algorithms, key rotation and policy are managed centrally. APISIX has no plugin that produces XML signatures today. #### What the plugin does During the `rewrite` phase, for requests whose method is in `request.methods`: 1. Rejects the request with `401` unless an authentication plugin attributed it to a Consumer (`request.require_consumer`, default `true`). 2. Checks `Content-Type` against `request.content_types`, rejects any `Content-Encoding` other than `identity` and any non-UTF-8 `charset`, and reads the body up to `request.max_body_bytes`. 3. Parses the XML with libxml2: no network access, no DTDs or entities, and no documents that are not namespace well-formed. 4. Rejects documents that already contain a `ds:Signature`. 5. Computes the reference digest over the whole document (`Reference URI=""`) with the enveloped-signature and exclusive canonicalization transforms. 6. Builds `ds:SignedInfo`, signs it with the configured RSA key, and adds `ds:KeyInfo` with the X.509 certificate unless `signature.key_info` is `none`. 7. Replaces the upstream body, updates `Content-Length`, and removes `Content-MD5`, `Digest`, `Content-Digest` and `Repr-Digest`, which no longer match the body. Requests with other methods pass through unchanged. #### Configuration | Attribute | Type | Default | Description | | --- | --- | --- | --- | | `credentials.certificate` | string | required | PEM X.509 certificate or a Secret reference (`$secret://…`, `$env://…`). | | `credentials.private_key` | string | required | PEM RSA private key or a Secret reference. Listed in `encrypt_fields`. | | `signature.algorithm` | string | `rsa-sha256` | `rsa-sha256`, or `rsa-sha1` for legacy verifiers. | | `signature.key_info` | string | `x509_data` | `x509_data` embeds the certificate; `none` omits `ds:KeyInfo`. | | `request.methods` | array | `["POST"]` | Methods to sign. | | `request.content_types` | array | `["application/xml", "text/xml"]` | Accepted media types. | | `request.max_body_bytes` | integer | `5242880` | 1024 to 67108864. Never raises the operator's `client_max_body_size` or a `client-control` limit. | | `request.require_consumer` | boolean | `true` | Sign only requests attributed to a Consumer. | Example: ```json { "uri": "/signed-xml", "plugins": { "key-auth": {}, "xml-signer": { "credentials": { "certificate": "$secret://vault/signing/xml/certificate", "private_key": "$secret://vault/signing/xml/private-key" } } }, "upstream": {"type": "roundrobin", "nodes": {"127.0.0.1:9001": 1}} } ``` #### Security design - **No signing oracle by default:** without an authenticated Consumer the plugin answers `401`, so anonymous clients cannot get documents signed with the gateway's key. - **Key strength:** `rsa-sha256` requires an RSA key of at least 2048 bits. `rsa-sha1` exists only for legacy verifiers: it accepts keys of at least 1024 bits and logs a warning on every signed request. - **Credentials:** they must be a matching RSA certificate and key pair, are parsed once and cached per worker (keyed by a SHA-256 fingerprint, so a rotated Secret takes effect without a reload), and a warning is logged if the certificate is expired or not yet valid. - **XXE and entity expansion:** documents with a DTD are rejected, and the parser runs with `XML_PARSE_NONET` and without `NOENT`/`DTDLOAD`. - **Parser DoS:** libxml2 2.9 (shipped in the Debian bookworm based images) parses some inputs super-linearly. A 5 MB document could keep a worker busy for over 13 seconds with namespace declarations, and for minutes with attributes. Before parsing, the plugin rejects documents in which one element has more than 256 attributes, or more than 128 namespace declarations are in scope at once. The check is linear and was differentially fuzzed against libxml2's own parse tree (57,000+ random documents, no undercounts). - **Namespace well-formedness:** recoverable libxml2 errors, such as an undeclared prefix, cause rejection, so the plugin never signs XML that namespace-aware verifiers would read differently. The error handler is created once per module load (LuaJIT has a fixed number of FFI callback slots), and the parser call is excluded from JIT compilation. - **Prefixes used only in content:** exclusive canonicalization does not cover a namespace declaration that is used only in content, such as the prefix in `xsi:type="ns:Type"`. The plugin lists such declarations in an `ec:InclusiveNamespaces` `PrefixList`, so changing their binding invalidates the signature. Documents without such declarations are signed exactly as without this feature. - **Body size:** the limit is enforced while NGINX reads the body (via APISIX-Runtime `set_client_max_body_size`), so a chunked upload without `Content-Length` cannot grow without bound. - **Errors:** responses contain only an error code, never request content or cryptographic details. Client-caused rejections are logged at `warn` level. #### Error responses | Status | Message | Cause | | --- | --- | --- | | `401` | `unauthorized` | No authenticated Consumer while `request.require_consumer` is `true`. | | `400` | `empty_body` | The request has no body. | | `400` | `invalid_xml` | Parse failure, a namespace error, no root element, a DTD, or the parser limits are exceeded. | | `400` | `already_signed` | The document already contains a `ds:Signature`. | | `413` | `body_too_large` | The body exceeds the effective size limit. | | `415` | `unsupported_media_type` | `Content-Type`, `Content-Encoding` or `charset` is not accepted. | | `500` | `credential_error` | A Secret could not be resolved, PEM parsing failed, the key is not RSA or too short, or the certificate and key do not match. | | `500` | `signing_error` | libxml2 is not available, or canonicalization, signing or serialization failed. | #### Implementation notes for reviewers - **Files:** `apisix/plugins/xml-signer.lua` (schema and entry point) and `apisix/plugins/xml-signer/`: `request.lua` (request pipeline), `signer.lua`, `xml.lua` (libxml2 FFI bindings and parser limits), `credentials.lua` and `algorithms.lua`. - **libxml2:** used through LuaJIT FFI; no new LuaRocks dependency is added. `libxml2.so.2` must be present at runtime. The official Debian, Ubuntu and Red Hat images include it; images built with apisix-docker's `debian-dev/Dockerfile.local` do not, as documented. Without libxml2 the plugin still loads, logs the reason once at startup, and answers signing requests with `500 signing_error`. - **FFI type definitions:** struct definitions are guarded, so the module can be reloaded (plugin hot reload) and coexist with other modules that declare the same libxml2 types. - **Priority 1026:** runs after the authentication plugins and after `body-transformer` (1080), and before `proxy-rewrite` and `proxy-mirror`, so later plugins see the signed body. Any plugin that modifies the body after `xml-signer` invalidates the signature; the docs state this. - **Plugin list:** the plugin is added to the default plugin list (`apisix/cli/config.lua`, `conf/config.yaml.example`, `t/admin/plugins.t`). It does nothing unless configured on a Route, but on hosts without libxml2 it logs the startup error described above. I'm happy to remove it from the default list if maintainers prefer. - **Also changed:** the `Makefile` install target, `README.md`, and the English and Chinese docs navigation (`docs/*/latest/config.json`). #### Tests - **`t/plugin/xml-signer.t`** (12 tests): - schema validation; - signing and independent verification for RSA-SHA256, RSA-SHA1 and no KeyInfo; - namespace conflicts and processing instructions, verified against an independently constructed canonical form; - rejection of DTD/XXE, already signed documents, non-RSA keys, and namespace errors; - minimum key sizes per algorithm; - parser limits at their exact boundaries, including decoys in comments, CDATA and attribute values, and 5 MB attack documents rejected in under 2 s; - the certificate validity warning; - `InclusiveNamespaces` (including that rebinding the prefix breaks the digest). - **`t/plugin/xml-signer2.t`** (3 tests), end to end through APISIX: - signing behind `key-auth`, and requests without a key are rejected; - pass-through of other methods; - `415`/`400`/`413` handling, including chunked bodies; - the operator `client_max_body_size` is never raised; - `401` without a Consumer, and the `require_consumer: false` opt-out. - **`t/plugin/xml-signer3.t`** (5 tests): - the request pipeline with more than 100 headers; - module reload and the credential cache; - normalization of defaults; - behavior without libxml2; - the Consumer check. Additional verification, not part of the test suite: - signatures verified with xmlsec1 1.2.39 for RSA-SHA256/2048-bit and RSA-SHA1/1024-bit, and tampered copies rejected; - the plugin tested in an APISIX 3.19.0 image built from source with apisix-docker's `Dockerfile.local`; - `make lint` (luacheck 1.2.0, lj-releng), eclint and markdownlint pass. ### Checklist - [x] I have explained the need for this PR and the problem it solves - [x] I have explained the changes or the new features added to this PR - [x] I have added tests corresponding to this change - [x] I have updated the documentation to reflect this change - [x] I have verified that this change is backward compatible (If not, please discuss on the [APISIX mailing list](https://github.com/apache/apisix/tree/master#community) first) -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
