Reference

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:

AdversaryCapabilityMitigation layer
Untrusted platformCan send arbitrary requests claiming to be an agent.Inbound signatures, capability negotiation, scope guard.
Malicious platform profileTries to point Shopware at internal services through UCP-Agent/iss.SSRF guard, DNS pinning, allowlist, profile validator.
Network attackerMITM or replays captured signed requests.RFC 9421 with created/expires, replay nonce, Content-Digest.
Compromised platform keySigns forged requests with stolen private key.Per-Sales-Channel key isolation, short signature lifetime, manual revocation.
Compromised Shopware keyReads ucp_signing_key rows from a stolen DB backup.AES-256-GCM at rest, HKDF per-row, key separate from APP_SECRET.
Hostile shopperTries to forge cursors, embedded sessions, idempotency keys, AP2 mandates.HMAC-bound cursors, hashed embedded session tokens, fingerprinted idempotency, cross-bound mandates.
Confused-deputy via OAuthSteals authorization codes, replays consent.PKCE, HMAC consent ticket + CSRF, fail-closed bearer auth, iss per RFC 9207, jti replay store.
Hostile log readerReads ucp logs to extract key material.KeyMaterialGuard Monolog processor, static analysis rule.
AI agent over-reachCharges 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/ucp returns 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:

  1. The inbound Host header is matched against sales_channel_domain rows by SalesChannelDomainResolver.
  2. The matching ucp_sales_channel_config row is loaded; if missing or inactive, the request is rejected with salesChannelNotConfigured.
  3. 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:

ThreatDefense
http:// plaintextHTTPS-only outside dev/test; non-prod localhost over HTTP is explicitly allowed for development containers.
Non-standard portsHTTPS profile URLs MUST be port 443.
User-info smugglingURLs containing user:pass@host are rejected.
IDN homograph attacksHostnames 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 rebindingDNS 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 redirectsCURLOPT_FOLLOWLOCATION=false, CURLOPT_MAXREDIRS=0. A redirect is treated as a hard error.
Unbounded response bodiesCapped at PlatformProfileFetcher::MAX_RESPONSE_BYTES = 256 KiB.
Slow-loris / hangsCONNECT_TIMEOUT_SECONDS = 5, RESPONSE_TIMEOUT_SECONDS = 10.
Cross-merchant traffic amplificationOptional platform_allowlist on the Sales Channel config; when set, only allowlisted hosts (or *.example.com patterns) are fetched.
TLS downgradeCURLOPT_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_linking cannot have its OAuth endpoints exercised through UCP.
  • Extensions like dev.ucp.shopping.ap2_mandate only 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):

  1. Parse Signature-Input and Signature headers.
  2. Verify Content-Digest per RFC 9530 — required when a body is present, validated as sha-256=:<digest>:.
  3. Resolve keyid against the platform's published JWKS, rejecting duplicate kid entries (otherwise a malicious platform could smuggle a second key with the same id).
  4. Enforce the time window: created/expires are required; creation in the future > 60 s skew is rejected; the validity window (expires - created) must be ≤ 300 s.
  5. Reconstruct the RFC 9421 signature base and verify the ECDSA signature against the resolved EC key (ES256/ES384).
  6. 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:

PolicyBehaviorRecommended for
strictMissing or invalid signatures reject the request. The default for new configs.Production.
logVerification failures are logged and the request continues, but downstream signal handling treats the request as unauthenticated.Local simulator testing, integration troubleshooting.
offSignature 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 kid is passed as additional authenticated data so a row whose kid is tampered with fails decryption.
  • APP_SECRET rotation is supported via PrivateKeyEncryptor::reencrypt and the bin/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 active key per Sales Channel at a time.
  • During rotation, the previous active transitions to retiring. Both active and retiring keys are published in /.well-known/ucp.signing_keys, but outbound signatures use the active key only.
  • UcpKeyRetirementTask (daily) transitions keys older than the 24h retiring window to retired.
  • UcpSigningKeyProvider::delete() will only delete keys that have been retired for at least RETIREMENT_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:

OperationPrivilege
List/read keysucp.viewer
List/read configucp.viewer
Modify configucp.editor
Create / rotate / retire / delete a keyucp.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:

  1. claim() INSERTs a pending row in ucp_idempotency_key keyed 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.
  2. 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.
  3. commit() updates the row with the final response after the controller returns. Subsequent retries with the same key replay the cached response with Idempotency-Replay: 1.
  4. 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). plain is not advertised.
  • authorization_response_iss_parameter_supported: true — per RFC 9207. OAuthAuthorizeController::appendIssToRedirect splices iss=<issuer> onto the redirect Location so mixed-up authorization responses are detectable client-side.

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_token is rendered into the consent form; the POST must echo it and it must match the cookie payload's csrf.
  • All OAuth-meaningful fields must match between GET (rendered) and POST (submitted) — a man-in-the-middle that swaps redirect_uri, state or PKCE parameters between the two requests is rejected.

10.3 Client authentication

ClientAuthenticator extends League's token endpoint with two extra client-auth methods:

MethodBehavior
nonePublic clients, but code_challenge_method=S256 is still mandatory.
client_secret_postConfidential 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 Authorization header → 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_id request attributes.

Capability controllers then call UcpScopeGuard::require() per operation:

ScopeTypical use
dev.ucp.shopping.cart:manageMutating cart operations.
dev.ucp.shopping.order:readReading order data.
dev.ucp.shopping.order:manageFuture 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:

  1. Origin is required and validated. EmbeddedController::resolveHostOrigin refuses to render the iframe without ?origin=https://host. Referer and wildcards are explicitly NOT accepted as fallbacks — both can be forged or omitted and would silently downgrade the security model to clickjacking.
  2. CSP frame-ancestors. The response carries Content-Security-Policy: frame-ancestors 'self' <hostOrigin>, denying embedding by any site other than the negotiated host.
  3. Short-lived session token. EmbeddedSessionFactory::issue mints a 32-byte cryptographic token and persists only its SHA-256 hash in ucp_embedded_session.session_token_hash. The token expires after TTL_SECONDS = 900 (15 min).
  4. Per-request session verification. Every embedded REST call must carry X-UCP-Embedded-Session: <token>. EmbeddedSessionFactory::verify requires an exact match on (token_hash, cart_id, sales_channel_id, host_origin) — a session issued for one cart cannot drive another.
  5. postMessage origin pinning. The embedded bridge JS targets the negotiated host origin exactly (window.parent.postMessage(msg, hostOrigin)) and rejects inbound messages whose e.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():

CheckThreat 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:

php
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 = 900 are rejected.
  • Mode is page or after.
  • Query-fingerprint binding — the cursor's q field 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:

bash
# 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 auth

When (and only when) both conditions are true:

Bridge behaviorProduction 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=dev in production.
  • Set UCP_CONFORMANCE_MODE=1 in 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_policy writes are normalised through an allowlist (UcpAdminConfigController::normaliseSignaturePolicy); unknown values silently downgrade to the secure default strict.
  • 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 UcpSigningKeyProvider API is interface-driven so a KMS-backed implementation can replace the AES-256-GCM-on-DB scheme without breaking callers. Until then, APP_SECRET is 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_SECRET rotation 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.