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]

Reply via email to