Security notes
This document explains the security architecture of Shopware's UCP
UCP Security Model
This document explains the security architecture of Shopware's UCP adapter end to end: what it defends against, how each defense is implemented, where the production guarantees live and where the test/conformance bridge differs. It is the canonical reference for plugin authors, operators and reviewers.
For the runtime handshake see flows.md; for field-level
mappings see mappings.md; for the JWT key-storage ADR
see ../shopware/adr/2026-05-20-ucp-jwt-key-storage-and-rotation.md.
1. Threat Model
UCP turns a Shopware storefront into a public API that AI agents can discover, browse and check out against. The protocol therefore has a materially different threat surface to the human-facing storefront:
| Adversary | Capability | Mitigation layer |
|---|---|---|
| Untrusted platform | Can send arbitrary requests claiming to be an agent. | Inbound signatures, capability negotiation, scope guard. |
| Malicious platform profile | Tries to point Shopware at internal services through UCP-Agent/iss. | SSRF guard, DNS pinning, allowlist, profile validator. |
| Network attacker | MITM or replays captured signed requests. | RFC 9421 with created/expires, replay nonce, Content-Digest. |
| Compromised platform key | Signs forged requests with stolen private key. | Per-Sales-Channel key isolation, short signature lifetime, manual revocation. |
| Compromised Shopware key | Reads ucp_signing_key rows from a stolen DB backup. | AES-256-GCM at rest, HKDF per-row, key separate from APP_SECRET. |
| Hostile shopper | Tries to forge cursors, embedded sessions, idempotency keys, AP2 mandates. | HMAC-bound cursors, hashed embedded session tokens, fingerprinted idempotency, cross-bound mandates. |
| Confused-deputy via OAuth | Steals authorization codes, replays consent. | PKCE, HMAC consent ticket + CSRF, fail-closed bearer auth, iss per RFC 9207, jti replay store. |
| Hostile log reader | Reads ucp logs to extract key material. | KeyMaterialGuard Monolog processor, static analysis rule. |
| AI agent over-reach | Charges more than the user authorised. | AP2 intent binding (cart total, currency, merchant host, line items). |
Out of scope: shop owners who configure Shopware against their own business rules (e.g. disabling signature verification on purpose), or shop owners who deploy the conformance bridge in production despite the explicit guard rails described in §15.
2. Defense Layers
UCP security is intentionally layered. Each layer is enforced independently; no single layer is allowed to be the sole gate.
Request from a platform
|
v
+---------------------------------------------------+
| L0 Feature flag UCP_SERVER | binary on/off
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L1 Domain resolution → Sales Channel | multi-tenant scoping
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L2 UCP active for this Sales Channel | per-channel toggle
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L3 UCP-Agent header / _meta.ucp-agent | required identity
| URL safety + SSRF guard |
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L4 Platform profile fetch + DNS pinning + cache | bounded I/O
| Discovery-budget enforcer |
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L5 Capability intersection | empty → reject
| Platform allowlist |
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L6 RFC 9421 signature verify | policy-driven
| RFC 9530 Content-Digest |
| Signature replay nonce |
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L7 Optional: OAuth bearer / scope guard | user-bound calls
| Optional: embedded session token | embedded protocol
| Optional: AP2 mandate verification | AP2 plugin
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| L8 Idempotency-Key claim (atomic) | state-changing ops
+---------------------------------------------------+
|
v
Controller runs
Each section below describes one layer.
3. Feature Flag Gate (L0)
The entire UCP server stack — discovery, runtime, admin API, scheduled
tasks and the OAuth Authorization Server — is gated by the UCP_SERVER
feature flag. With the flag disabled:
/.well-known/ucpreturns 404 unconditionally (WellKnownUcpController::profile).- Every UCP route resolver throws
UcpException::featureDisabled(). - Every admin API endpoint short-circuits to a 404
(
UcpAdminConfigController::guardFeatureFlag(),UcpAdminKeyController::guardFeatureFlag()). - The OAuth authorization endpoint returns 404 even if the URL is
hit directly (
OAuthAuthorizeController::authorize).
To activate UCP for an installation, the operator sets
UCP_SERVER=1 (env) or runs bin/console feature:enable UCP_SERVER.
This is the only flag that disables the entire feature.
4. Sales-Channel Scoping (L1 + L2)
Every UCP-relevant entity lives under a sales_channel_id. The
resolution chain is:
- The inbound
Hostheader is matched againstsales_channel_domainrows bySalesChannelDomainResolver. - The matching
ucp_sales_channel_configrow is loaded; if missing or inactive, the request is rejected withsalesChannelNotConfigured. - All subsequent reads (signing keys, OAuth clients, idempotency rows, embedded sessions, signature nonces, negotiation sessions) are filtered by that Sales Channel ID.
The implication is that a single Shopware install hosting eu.shop and
us.shop publishes two independent UCP profiles with independent
signing keys, OAuth servers, allowlists and capability sets. A
compromise of one Sales Channel's keys never affects another channel.
Cascading deletes are enforced at the DB level: removing a
sales_channel row also removes its ucp_signing_key,
ucp_idempotency_key, ucp_oauth_*, ucp_embedded_session,
ucp_signature_nonce, ucp_negotiation_session and
ucp_buyer_consent rows.
5. Profile Fetching, SSRF Defense and DNS Pinning (L3 + L4)
Every UCP request carries a UCP-Agent: profile="<https-url>" header.
Shopware MUST then dereference that URL to obtain the platform's JWKS
and capability set. Untrusted URL handling is the single largest attack
surface in the entire flow.
UrlSafetyValidator::validateAndResolve() and
PlatformProfileFetcher::requestPinned() cooperate to close the
following threats:
| Threat | Defense |
|---|---|
http:// plaintext | HTTPS-only outside dev/test; non-prod localhost over HTTP is explicitly allowed for development containers. |
| Non-standard ports | HTTPS profile URLs MUST be port 443. |
| User-info smuggling | URLs containing user:pass@host are rejected. |
| IDN homograph attacks | Hostnames are normalised via idn_to_ascii(UTS46); mixed-script labels rejected. |
| RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) | Rejected outside dev/test through FILTER_FLAG_NO_PRIV_RANGE. |
| Loopback (127.0.0.0/8, ::1) | Rejected outside dev/test through FILTER_FLAG_NO_RES_RANGE. |
| Link-local (169.254/16, fe80::/10) | Rejected outside dev/test. |
Cloud metadata (169.254.169.254, fd00:ec2::254, metadata.google.internal) | Rejected unconditionally — even in dev mode. |
| DNS rebinding | DNS is resolved once with dns_get_record; the resolved IP is validated, then pinned via CURLOPT_RESOLVE for the actual fetch. The HTTP client cannot resolve the host again to a different IP. |
| HTTP redirects | CURLOPT_FOLLOWLOCATION=false, CURLOPT_MAXREDIRS=0. A redirect is treated as a hard error. |
| Unbounded response bodies | Capped at PlatformProfileFetcher::MAX_RESPONSE_BYTES = 256 KiB. |
| Slow-loris / hangs | CONNECT_TIMEOUT_SECONDS = 5, RESPONSE_TIMEOUT_SECONDS = 10. |
| Cross-merchant traffic amplification | Optional platform_allowlist on the Sales Channel config; when set, only allowlisted hosts (or *.example.com patterns) are fetched. |
| TLS downgrade | CURLOPT_SSL_VERIFYPEER=true, CURLOPT_SSL_VERIFYHOST=2. |
After a successful fetch, the JSON body is validated by
PlatformProfileValidator (mandatory ucp object, valid version
format, signing_keys array if present, capability-spec authority
matches the reverse-domain namespace) and cached in
ucp_platform_profile_cache for at least 60 s with stale-while-revalidate
fallback.
Outbound traffic is additionally rate-limited by
DiscoveryBudgetEnforcer:
- Default 120 fetches/minute globally.
- Per-origin failure counter; 5 consecutive failures opens a 10-minute backoff window for that origin.
The same validateAndResolve() is reused for outbound order webhook
URLs (OrderWebhookPublisher::resolveWebhookUrl) so a malicious
platform profile cannot weaponise the webhook channel into an SSRF
either.
6. Capability Negotiation and Platform Allowlist (L5)
NegotiationOrchestrator::negotiate() computes the intersection of
business-enabled capabilities and platform-published capabilities. An
empty intersection rejects the request before any controller runs
(UcpException::capabilitiesIncompatible). This means:
- A platform that does not declare a capability cannot accidentally invoke it (extension surface stays closed).
- A business that has not enabled
dev.ucp.common.identity_linkingcannot have its OAuth endpoints exercised through UCP. - Extensions like
dev.ucp.shopping.ap2_mandateonly become active when both sides explicitly opt in.
ucp_sales_channel_config.platform_allowlist adds an additional
opt-in list of host patterns. When set, every platform profile fetch
and every OAuth client_id is matched against it
(OAuthAuthorizeController::isClientAllowed).
7. HTTP Message Signatures (RFC 9421) — L6
7.1 Inbound verification
Rfc9421SignatureVerifier::verifyRequest() performs (in order):
- Parse
Signature-InputandSignatureheaders. - Verify
Content-Digestper RFC 9530 — required when a body is present, validated assha-256=:<digest>:. - Resolve
keyidagainst the platform's published JWKS, rejecting duplicatekidentries (otherwise a malicious platform could smuggle a second key with the same id). - Enforce the time window:
created/expiresare required; creation in the future > 60 s skew is rejected; the validity window (expires - created) must be ≤ 300 s. - Reconstruct the RFC 9421 signature base and verify the ECDSA
signature against the resolved EC key (
ES256/ES384). - Register the verified signature with
SignatureReplayGuard::rememberOrThrow()— a SHA-256 of the raw signature, scoped to(sales_channel_id, kid), with a 10-minute retention. A duplicate signature within that window is a hard replay rejection.
7.2 Signature policies
ucp_sales_channel_config.signature_policy is the operator-controlled
behavior knob:
| Policy | Behavior | Recommended for |
|---|---|---|
strict | Missing or invalid signatures reject the request. The default for new configs. | Production. |
log | Verification failures are logged and the request continues, but downstream signal handling treats the request as unauthenticated. | Local simulator testing, integration troubleshooting. |
off | Signature verification disabled entirely. Signals are never trusted in this mode. | Local development only. |
The UcpAdminConfigController::normaliseSignaturePolicy() whitelist
guarantees that an unknown value falls back to strict — a malformed
admin payload cannot push an unrecognised state into the DB.
7.3 Outbound signing
Outbound order webhooks (OrderWebhookPublisher) and the AP2 merchant
authorization (MandateVerifier::signCheckoutState) are signed using
the same Rfc9421SignatureBuilder primitives and the same per-Sales-Channel
active signing key. Outbound signatures use expires = created + 300 s
and carry Content-Digest, Signature-Input, Signature, plus
Webhook-Id and Webhook-Timestamp headers for
Standard-Webhooks-style platform-side dedup.
8. Signing Key Lifecycle and Encryption at Rest
8.1 Key generation
EcKeyGenerator::generate() produces an EC keypair (prime256v1 for
ES256, secp384r1 for ES384) using OpenSSL. The kid is derived
deterministically from the EC public point + algorithm
(sha256(x ‖ y ‖ alg)[:16], year-prefixed) so two distinct keys can
never collide on an identifier.
8.2 Encryption at rest
PrivateKeyEncryptor encrypts the PEM-encoded private key with
AES-256-GCM before storing it in
ucp_signing_key.private_key_pem_encrypted:
- The 256-bit content encryption key is derived per row via
hash_hkdf('sha256', APP_SECRET, 32, kid, 'ucp/signing-key-v1'). Each row therefore uses a distinct CEK; ciphertexts cannot be shuffled between rows. - The wire format is
1 byte version | 12 bytes IV | 16 bytes tag | ciphertext. - The
kidis passed as additional authenticated data so a row whosekidis tampered with fails decryption. APP_SECRETrotation is supported viaPrivateKeyEncryptor::reencryptand thebin/console ucp:keys:reencrypt --old-secret=… --new-secret=…command.
8.3 Rotation finite state machine
create new 24h grace
[absent] --------> [active] --------------> [retiring] --24h--> [retired] -----> [deleted]
| ^
| rotate |
+-----------------------+
- Exactly one
activekey per Sales Channel at a time. - During rotation, the previous
activetransitions toretiring. Bothactiveandretiringkeys are published in/.well-known/ucp.signing_keys, but outbound signatures use theactivekey only. UcpKeyRetirementTask(daily) transitions keys older than the 24h retiring window toretired.UcpSigningKeyProvider::delete()will only delete keys that have beenretiredfor at leastRETIREMENT_GRACE_PERIOD_SECONDS = 7 days. UCP signatures spec recommends ≥ 7 days so platforms with 24h+ profile caches can still verify in-flight signatures.
8.4 Log scrubbing and static analysis
KeyMaterialGuard is a Monolog processor that strips any log-context
key matching private_key, private_jwk, pem_encrypted,
signing_secret, or jwk.d — replacing the value with [redacted].
A custom PHPStan rule rejects log calls whose variable names match the
same patterns. Together these provide defence-in-depth against an
extension accidentally dumping key material.
8.5 Admin API ACL
Key-mutating operations are gated by the dedicated ucp.key_rotator
ACL privilege, separate from ucp.editor:
| Operation | Privilege |
|---|---|
| List/read keys | ucp.viewer |
| List/read config | ucp.viewer |
| Modify config | ucp.editor |
| Create / rotate / retire / delete a key | ucp.key_rotator |
The ACL mapping lives in
src/Administration/Resources/app/administration/src/module/sw-settings-ucp/acl/index.js
and is enforced server-side on every admin route via Symfony
_acl attribute.
9. Idempotency and Atomic Replay Protection (L8)
IdempotencyStore implements a two-phase commit pattern that closes
the TOCTOU race the obvious "lookup → run → store" flow would have:
claim()INSERTs apendingrow inucp_idempotency_keykeyed uniquely by(sales_channel_id, idempotency_key)before the controller runs. Concurrent retries with the same key collide on the UNIQUE constraint; only one wins.- The fingerprint (route name | method | path | sorted query | body) is stored alongside the claim. A reused key with a different body is rejected with HTTP 409.
commit()updates the row with the final response after the controller returns. Subsequent retries with the same key replay the cached response withIdempotency-Replay: 1.abort()deletes the row on a 5xx so a fresh retry can run instead of being stuck on a permanent failure.
Idempotency is required for ucp.cart.create, ucp.cart.update,
ucp.cart.cancel, ucp.cart.discount.apply, ucp.checkout.create,
ucp.checkout.update, ucp.checkout.complete and ucp.checkout.cancel
when ucp_sales_channel_config.idempotency_required = true (default
on). Rows expire after RETENTION_HOURS = 48 h.
The fingerprint uses SHA-256 (not Shopware's Hasher abstraction)
intentionally — replay detection demands cryptographic collision
resistance, not just deterministic hashing.
10. OAuth 2.0 Identity Linking
When dev.ucp.common.identity_linking is in the negotiated intersection
the Sales Channel exposes a full OAuth 2.0 Authorization Server.
10.1 Discovery and PKCE
/.well-known/oauth-authorization-server advertises:
response_types_supported: [code]— implicit and hybrid are not offered.grant_types_supported: [authorization_code, refresh_token]— client_credentials and password grants are deliberately omitted.code_challenge_methods_supported: [S256]— PKCE S256 is required for every client (public or confidential).plainis not advertised.authorization_response_iss_parameter_supported: true— per RFC 9207.OAuthAuthorizeController::appendIssToRedirectsplicesiss=<issuer>onto the redirect Location so mixed-up authorization responses are detectable client-side.
10.2 Consent ticket and CSRF
The GET → POST consent step is protected by a short-lived HMAC consent ticket:
- HMAC key:
sha256(APP_SECRET || '|ucp-oauth-consent'). - Payload:
iat,exp(300 s TTL),csrf(32 random bytes),client_id,redirect_uri,scope,state,code_challenge,code_challenge_method. - Delivered as a cookie marked
HttpOnly,Secure(when the request is secure),SameSite=Lax. - A hidden
_csrf_tokenis rendered into the consent form; the POST must echo it and it must match the cookie payload'scsrf. - All OAuth-meaningful fields must match between GET (rendered) and
POST (submitted) — a man-in-the-middle that swaps
redirect_uri,stateor PKCE parameters between the two requests is rejected.
10.3 Client authentication
ClientAuthenticator extends League's token endpoint with two extra
client-auth methods:
| Method | Behavior |
|---|---|
none | Public clients, but code_challenge_method=S256 is still mandatory. |
client_secret_post | Confidential client_secret in form body, handled by League. |
private_key_jwt (RFC 7523) | ES256/ES384 client assertion; iss==sub==client_id, aud equal to the token endpoint URL, exp within 60 s skew, jti MUST be present and not previously seen for this (sales_channel, iss), JWKS resolved from the pinned jwks_json or the client's platform profile. |
tls_client_auth (RFC 8705) | mTLS — Shopware reads the certificate Subject DN from the server-injected SSL_CLIENT_S_DN only; client-supplied X-SSL-* headers are explicitly ignored to prevent spoofing through a reverse proxy that forgets to strip them. |
JTI replay protection is implemented through the
ucp_oauth_client_assertion table; a duplicate (sales_channel, iss, jti)
triggers oauthClientAuthFailed.
10.4 Bearer-token validation (fail-closed)
UcpAccessTokenAuthenticator::authenticate():
- No
Authorizationheader → no-op; the request is anonymous and downstream code decides whether anonymous access is acceptable. - Invalid bearer token → throws. This is the F5 bug class: silently dropping the bearer would let downstream code treat the caller as anonymous, which is worse than an explicit 401.
- Valid bearer → sets
_ucp_user_id,_ucp_scopes,_ucp_client_idrequest attributes.
Capability controllers then call UcpScopeGuard::require() per
operation:
| Scope | Typical use |
|---|---|
dev.ucp.shopping.cart:manage | Mutating cart operations. |
dev.ucp.shopping.order:read | Reading order data. |
dev.ucp.shopping.order:manage | Future order management operations. |
10.5 Token signing
Access tokens are JWTs signed with the Sales Channel's UCP signing key
(ES256/ES384). The same JWKS published in /.well-known/ucp.signing_keys
is therefore reusable as the OAuth resource-server JWKS, and the
discovery metadata's jwks_uri points at /.well-known/ucp.
11. Embedded Protocol Security
The Embedded Protocol (/ucp/embedded/cart/{cartId},
/ucp/embedded/checkout/{cartId}) renders an iframe served from
Shopware that talks to a host site over window.postMessage. Its
security model relies on five orthogonal controls:
- Origin is required and validated.
EmbeddedController::resolveHostOriginrefuses to render the iframe without?origin=https://host.Refererand wildcards are explicitly NOT accepted as fallbacks — both can be forged or omitted and would silently downgrade the security model to clickjacking. - CSP frame-ancestors. The response carries
Content-Security-Policy: frame-ancestors 'self' <hostOrigin>, denying embedding by any site other than the negotiated host. - Short-lived session token.
EmbeddedSessionFactory::issuemints a 32-byte cryptographic token and persists only its SHA-256 hash inucp_embedded_session.session_token_hash. The token expires afterTTL_SECONDS = 900(15 min). - Per-request session verification. Every embedded REST call must
carry
X-UCP-Embedded-Session: <token>.EmbeddedSessionFactory::verifyrequires an exact match on(token_hash, cart_id, sales_channel_id, host_origin)— a session issued for one cart cannot drive another. - postMessage origin pinning. The embedded bridge JS targets the
negotiated host origin exactly (
window.parent.postMessage(msg, hostOrigin)) and rejects inbound messages whosee.origin !== hostOrigin. Wildcard*targets are never used.
In addition, the iframe page sets Cache-Control: no-store, private
and Referrer-Policy: no-referrer so the URL containing the cart
token does not leak into Referer logs or shared caches.
12. AP2 Mandate Verification (SwagUcpAp2Mandates)
When the dev.ucp.shopping.ap2_mandate extension is in the negotiated
intersection, Shopware enforces a layered mandate verification on
every checkout/complete request through
Swag\UcpAp2Mandates\Service\MandateVerifier::verifyMandates():
| Check | Threat closed |
|---|---|
Issuer pinning — iss MUST equal the negotiated platform profile URI (hash_equals on normalised URL). | An attacker cannot point iss at any HTTPS URL to trigger an arbitrary JWKS fetch (SSRF amplifier + allowlist bypass). |
Algorithm allowlist — only ES256 and ES384 accepted. | none-alg attacks; HMAC algorithm-confusion against an EC public key. |
Algorithm-confusion guard — JWS header alg MUST equal JWKS entry's alg when present. | Header swap to a weaker algorithm against the same key. |
kid resolution against the issuer's published JWKS, no duplicate kid. | Smuggling a second key with the same id. |
Time-bounds — exp required, ±60 s skew tolerance, lifetime ≤ 900 s, nbf/iat checked. | Long-lived mandates sitting in an attacker's inventory. |
aud pinning — ucp:checkout for the checkout mandate, ucp:payment for the payment mandate. | Cross-protocol mandate reuse. |
Cross-binding — payment mandate's cm_ref MUST equal checkout mandate's jti. | Mandate-swap attack pairing a stale checkout mandate with a fresh payment one. |
Subject binding — both mandates' sub MUST be identical. | Cross-user mandate relay. |
Replay protection — jti MUST be present; uniqueness enforced one level up in the subscriber via atomic INSERT into swag_ucp_ap2_mandate_log. | Same mandate accepted twice. |
Intent binding (assertIntentMatchesCart) — exact merchant-host match (hash_equals on lower-cased host), currency match, cart total ≤ authorised total + 1 minor unit tolerance, every intent.items[] present in the cart with at least the authorised quantity. | Cross-merchant mandate relay; agent overspending the user's authorisation; substituting one product for another. |
SD-JWT VC + Key-Binding (verifySdJwt) — disclosures matched to _sd[] hashes; cnf.jwk extracted; KB-JWT verified with typ=kb+jwt, fresh iat, sd_hash matching base64url(sha256(prefix)), holder-key signature verified. | Disclosure forgery; mandate-without-key-binding presentation. |
The merchant's reciprocal proof (ap2.merchant_authorization) is a
detached JWS over the JCS-canonicalized checkout response with the
ap2 field stripped, signed with the active Sales Channel signing
key. JCS (RFC 8785) ensures bit-for-bit identical signature bytes
across implementations regardless of property insertion order or
number formatting.
The replay log
(swag_ucp_ap2_mandate_log) deliberately stores only mandate IDs,
user subject, and payment mandate ID — not full PII payloads.
13. Signals — Conditional Trust
SignalsExtractor::extract() only honours platform-provided signals
when the inbound request was cryptographically authenticated:
if (!$signatureVerified) {
return [];
}This binding is made by UcpAgentRequestResolver, which stores the
verification result in UcpRequestContext::ATTR_SIGNATURE_VERIFIED.
When signature_policy = log and the signature fails verification,
the request continues but signals are silently dropped. With
signature_policy = off or strict + missing signature, the request
either dies entirely (strict) or proceeds without signals (off).
Trusted namespaces are dev.ucp.* plus any namespaces declared in
the platform allowlist's trust_signal_namespace entries. Untrusted
namespaces are dropped silently (per spec, non-fatal). Control
characters in signal names trigger
UcpException::signalsUntrusted. Signals are capped at
MAX_SIGNALS = 32 per request to prevent DoS through unbounded arrays.
14. Catalog Cursors
CursorCodec (Capability/Catalog/CursorCodec.php) emits opaque
pagination tokens that are HMAC-signed with a key derived from
APP_SECRET:
base64url(payload_json) . '.' . base64url(hmac_sha256(payload_json, key))
The HMAC key is derived as
sha256(APP_SECRET || '|ucp-catalog-cursor-v1') so the same secret
cannot accidentally produce a cursor that validates against another
UCP HMAC use-case (consent ticket, embedded session).
decode() enforces:
- Constant-time signature comparison (
hash_equals). - Version match (
v == 1). - TTL — cursors older than
MAX_AGE_SECONDS = 900are rejected. - Mode is
pageorafter. - Query-fingerprint binding — the cursor's
qfield MUST equal the current request's query fingerprint (fingerprint(query, filters)), preventing a cursor issued for one query from "carrying over" into an unrelated query and surfacing confusingly mixed results.
A client cannot forge a cursor that points elsewhere in the catalogue
without APP_SECRET.
15. Conformance / Test Mode (Production Boundary)
The official upstream conformance suite makes assumptions that are
incompatible with production hardening — for example it sends
request-signature: test instead of a real RFC 9421 signature, and
uses UCP-Agent: profile="..." as a literal placeholder. Shopware
supports the upstream suite through an explicit non-production bridge,
which is guard-railed at two layers:
# BOTH conditions must hold for the bridge to activate
APP_ENV != prod # checked first in every conformance gate
UCP_CONFORMANCE_MODE=1 # opt-in for the local conformance runner
UCP_SIMULATION_SECRET=<...> # optional, enables HMAC simulator authWhen (and only when) both conditions are true:
| Bridge behavior | Production behavior |
|---|---|
request-signature: test accepted as a valid signature. | Rejected; only RFC 9421 signatures count. |
UCP-Agent: profile="..." placeholder accepted; capability intersection is computed against the AP2-stripped business set. | Rejected as an invalid profile URL. |
request-signature: sha256=<HMAC> HMAC variant accepted using UCP_SIMULATION_SECRET. | Rejected. |
localhost/127.0.0.1 profile fetch falls back to host.docker.internal so the upstream Python suite running on the Docker host is reachable from the container. | No fallback; private IPs are rejected outright. |
Conformance-only discount codes (10OFF, WELCOME20, FIXED500) become active. | Only real Shopware promotions resolve. |
bin/console ucp:conformance:seed can install the flower-shop fixture. | Command no-ops on prod. |
| Deterministic fulfillment/order helpers, simulation shipping endpoint, persistent adjustment ledger. | Real Shopware delivery and shipping state machines. |
UcpAgentRequestResolver::isConformanceMode() returns false
unconditionally when APP_ENV === 'prod', regardless of
UCP_CONFORMANCE_MODE. There is no toggle, no CLI flag and no admin
setting that can reactivate the bridge against a production
environment — the gate is structural.
Operators MUST NOT:
- Set
APP_ENV=devin production. - Set
UCP_CONFORMANCE_MODE=1in production. - Treat conformance-mode behavior as production semantics — the bridge exists exclusively to satisfy upstream fixture assumptions.
The simulator HMAC variant (UCP_SIMULATION_SECRET) is the stronger
of the two simulation auth modes; request-signature: test exists
because the official upstream suite currently sends that literal
string and refusing it would block conformance certification.
16. Admin Surface Hardening
The admin API exposes a single set of namespaced privileges
(ucp.viewer, ucp.editor, ucp.key_rotator) and a small input
validation surface:
signature_policywrites are normalised through an allowlist (UcpAdminConfigController::normaliseSignaturePolicy); unknown values silently downgrade to the secure defaultstrict.- The signing key endpoints expose only the public JWK, status and timestamps — there is no admin API path that returns the private key material.
- The admin API short-circuits every endpoint with
Feature::isActive('UCP_SERVER'), so an attacker that hits the admin URLs while the feature is disabled gets 404 instead of 500. - The CLI commands that operate on key material
(
ucp:keys:list,ucp:keys:create,ucp:keys:rotate,ucp:keys:retire,ucp:keys:reencrypt) inherit Symfony's console permission model (typically root/CI/operations user only).
17. Outbound Order Webhooks
OrderWebhookPublisher::publish() signs every outbound order
notification using the Sales Channel's active UCP signing key and the
same Rfc9421SignatureBuilder machinery as inbound verification:
Content-Digest: sha-256=:<digest>:Signature-Input: sig1=(...); created=...; expires=...; keyid=...; tag="ucp"Signature: sig1=:<base64>:Webhook-Id,Webhook-Timestamp(Standard-Webhooks compatibility).
Outbound URLs are re-validated through UrlSafetyValidator before
the request is dispatched, so an attacker who managed to push a
malicious webhook_url into a cached platform profile cannot pivot
that into an SSRF amplifier through the webhook channel.
Webhook envelopes only carry order-relevant capabilities
(dev.ucp.shopping.order, dev.ucp.shopping.ap2_mandate); the full
negotiated intersection is intentionally NOT leaked so a platform
does not learn about other extensions it has not opted into.
Webhook dispatch failures are logged at warning level via the ucp
Monolog channel — but never block the order state-machine
transition.
18. Known Limitations and Future Work
The following items are explicitly acknowledged as not-yet-implemented; they are tracked for follow-up.
- HSM/KMS-backed key storage is not in PR #1. The
UcpSigningKeyProviderAPI is interface-driven so a KMS-backed implementation can replace the AES-256-GCM-on-DB scheme without breaking callers. Until then,APP_SECRETis the single root of trust for UCP private keys at rest. - EdDSA (Ed25519) is not supported; only EC
ES256/ES384. - Per-tenant rate limiting beyond discovery is not yet
implemented at the UCP layer. Operators are expected to apply
upstream rate limits (reverse proxy / WAF) for
/ucp/v1/*traffic. - Cross-Sales-Channel SSO is intentionally not exposed; each channel runs its own OAuth Authorization Server. A federation layer may be added later via a separate ADR.
- The
APP_SECRETrotation procedure is supported (ucp:keys:reencrypt) but currently single-pass — there is no online dual-secret window. Operators should schedule a brief maintenance period for the rotation.
19. Reporting Security Issues
UCP runs under the same security disclosure policy as the rest of
Shopware. Issues affecting UCP specifically should mention the
UCP_SERVER flag in the report so the triage team can route them
correctly.
Do not open public GitHub issues for security findings; follow
the standard SECURITY.md reporting channel of the main Shopware
repository.