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]