keremoge opened a new pull request, #14174:
URL: https://github.com/apache/apisix/pull/14174

   ### Description
   
   This PR adds a new plugin, `soap-signer`, which signs SOAP 1.1 and SOAP 1.2
   request messages with an X.509 WS-Security signature (OASIS WS-Security X.509
   Token Profile) before APISIX proxies them upstream.
   
   #### Motivation
   
   Many enterprise, government and financial SOAP services require every request
   to carry a `wsse:Security` header with a signed Body and a signed
   `wsu:Timestamp`. Today, every client has to integrate a WS-Security stack, 
such
   as WSS4J or WCF, and hold the signing key. With this plugin, the gateway
   signs instead:
   
   - the private key stays in APISIX, ideally through an APISIX Secret
     reference;
   - clients send plain SOAP and authenticate with an APISIX auth plugin;
   - algorithms, timestamp lifetime and key rotation are managed centrally.
   
   APISIX has no plugin that produces WS-Security 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`, `Content-Encoding` and `charset`, and reads the 
body up
      to `request.max_body_bytes`.
   3. Parses the message with libxml2: no network access, no DTDs or entities,
      and no messages that are not namespace well-formed.
   4. Detects SOAP 1.1 or SOAP 1.2 from the Envelope namespace and enforces
      `soap.version`. It requires exactly one Body, keeps an existing Header
      (which must precede the Body) or creates one, and rejects messages that
      already contain a `ds:Signature`.
   5. Validates XML IDs (`wsu:Id`, `xml:id`, unqualified `Id`): they must be 
valid
      NCNames and unique, which prevents ID-based signature-wrapping ambiguity.
   6. Reuses the `wsse:Security` header that targets the ultimate receiver (no
      SOAP 1.1 `actor` or SOAP 1.2 `role`, or the ultimateReceiver role), or
      creates one. More than one such header is rejected. A reused header must
      be empty unless `soap.allow_existing_security_content` is `true`, and may
      never already contain a `wsu:Timestamp`.
   7. Sets SOAP `mustUnderstand` (`1`/`0` for SOAP 1.1, `true`/`false` for SOAP
      1.2), assigns a random `wsu:Id` to the Body if it has none, and adds a UTC
      `wsu:Timestamp` with `Created` and `Expires` (`timestamp.ttl_seconds`).
   8. Unless `signature.key_info` is `none`, embeds the certificate as a
      `wsse:BinarySecurityToken` (X509v3) and references it from `ds:KeyInfo`
      through a `wsse:SecurityTokenReference` direct reference.
   9. Signs the Body and the Timestamp, each with an exclusive canonicalization
      transform, and replaces the upstream body, updating `Content-Length` and
      removing body digest headers.
   
   Requests with other methods pass through unchanged.
   
   #### Configuration
   
   | Attribute | Type | Default | Description |
   | --- | --- | --- | --- |
   | `credentials.certificate` | string | required | PEM X.509 certificate or a 
Secret reference. |
   | `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 services. |
   | `signature.key_info` | string | `binary_security_token` | 
`binary_security_token`, or `none` to omit the token and `ds:KeyInfo`. |
   | `soap.version` | string | `auto` | `auto`, `1.1` or `1.2`. |
   | `soap.must_understand` | boolean | `true` | Value of `mustUnderstand` on 
the Security header. |
   | `soap.allow_existing_security_content` | boolean | `false` | Allow reusing 
a `wsse:Security` header that already has content (that content is not signed). 
|
   | `timestamp.ttl_seconds` | integer | `300` | 1 to 86400. Lifetime between 
`Created` and `Expires`. |
   | `request.methods` | array | `["POST"]` | Methods to sign. |
   | `request.content_types` | array | `["text/xml", "application/soap+xml", 
"application/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-soap",
     "plugins": {
       "key-auth": {},
       "soap-signer": {
         "credentials": {
           "certificate": "$secret://vault/signing/soap/certificate",
           "private_key": "$secret://vault/signing/soap/private-key"
         },
         "timestamp": {"ttl_seconds": 300}
       }
     },
     "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`.
   - **No unsigned content next to the gateway signature by default:**
     client-supplied content in a reused `wsse:Security` header, such as a
     `UsernameToken`, is rejected unless `soap.allow_existing_security_content`
     is enabled.
   - **Key strength:** `rsa-sha256` requires an RSA key of at least 2048 bits.
     `rsa-sha1` exists only for legacy services: 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
     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.
   - **Random values:** IDs for the Timestamp and BinarySecurityToken come from
     16 bytes of `resty.random.bytes(16, true)` (a cryptographically secure
     generator).
   - **XXE and entity expansion:** DTDs are rejected, and the parser runs with
     `XML_PARSE_NONET` and without `NOENT`/`DTDLOAD`.
   - **Parser DoS:** libxml2 2.9 parses some inputs super-linearly. Before
     parsing, the plugin rejects messages 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. This also bounds the prefix-collision search used to declare 
the
     `wsse`, `wsu`, `ds` and SOAP prefixes, which was quadratic before.
   - **Namespace well-formedness:** recoverable libxml2 errors cause rejection.
     The structured 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; creating the handler per request exhausted the callback slots
     after about 900 requests per worker.
   - **Prefixes used only in content:** a namespace declaration used only in
     content, such as the prefix in `xsi:type="xsd:string"`, is listed in an
     `ec:InclusiveNamespaces` `PrefixList` on the Body reference, so changing 
its
     binding invalidates the signature. Messages without such declarations are
     signed exactly as without this feature.
   - **Body size:** the limit is enforced while NGINX reads the body, so chunked
     uploads without `Content-Length` are bounded.
   - **Errors:** responses contain only an error code. 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_soap` | Parse or namespace error, a missing or duplicated 
Body, a duplicated or misplaced Header, invalid or duplicated XML IDs, an 
unacceptable reused Security header, a DTD, or the parser limits are exceeded. |
   | `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 the effective size limit. |
   | `415` | `unsupported_media_type` | `Content-Type`, `Content-Encoding` or 
`charset` is not accepted. |
   | `500` | `credential_error` | Credential resolution, parsing, key type or 
size, or certificate/key mismatch. |
   | `500` | `signing_error` | libxml2 is not available, or ID generation, 
canonicalization, signing or serialization failed. |
   
   #### Implementation notes for reviewers
   
   - **Files:** `apisix/plugins/soap-signer.lua` (schema and entry point) and
     `apisix/plugins/soap-signer/`: `request.lua`, `signer.lua` (envelope 
handling
     and WS-Security construction), `xml.lua` (libxml2 FFI bindings, parser
     limits, canonicalization), `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 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 and coexist with other modules that declare the same libxml2
     types.
   - **Priority 1027:** runs after the authentication plugins,
     `request-decompress` and `body-transformer`, and before `proxy-rewrite` and
     `proxy-mirror`. Any plugin that changes the Body, the Timestamp or 
namespace
     declarations after `soap-signer` invalidates the signature; the docs state
     this.
   - **Plugin list:** the plugin is added to the default plugin list. 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.
   
   #### Tests
   
   - **`t/plugin/soap-signer.t`** (16 tests):
     - schema validation;
     - signing and independent verification for SOAP 1.1 and SOAP 1.2,
       RSA-SHA1, and no KeyInfo;
     - rejection of existing signatures, malformed envelopes, DTDs, duplicate or
       invalid IDs, and namespace errors;
     - Security header reuse and Timestamp rules;
     - default-namespace Body content;
     - ultimate-receiver header selection;
     - namespace bindings when adding the security namespaces;
     - minimum key sizes per algorithm;
     - parser limits, including the prefix-collision attack;
     - the certificate validity warning;
     - defaults when called directly;
     - the empty-header rule;
     - `InclusiveNamespaces` (including that rebinding the prefix breaks the
       digest).
   - **`t/plugin/soap-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 opt-out.
   - **`t/plugin/soap-signer3.t`** (7 tests):
     - the request pipeline with more than 100 headers;
     - module reload and the credential cache;
     - SOAP 1.1 and 1.2 canonical forms constructed independently;
     - normalization of defaults;
     - parsing 5,000 documents without exhausting FFI callback slots;
     - behavior without libxml2;
     - the Consumer check.
   
   Additional verification, not part of the test suite:
   
   - signatures verified with xmlsec1 1.2.39 for SOAP 1.1 and 1.2, RSA-SHA256 
and
     RSA-SHA1 with a 1024-bit key, with and without KeyInfo, and tampered copies
     rejected;
   - the plugin tested end to end, including 1,500 signing requests on one
     worker, in an APISIX 3.19.0 image built from source;
   - `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