Shopware: SSRF in Media External-Link Endpoint Bypasses IP Validation
Description
Summary
The /api/_action/media/external-link endpoint allows authenticated admin users to make server-side HTTP HEAD requests to arbitrary internal IP addresses. While the parallel uploadFromURL flow validates target IPs against private/reserved ranges via FileUrlValidator, the linkURL flow only performs a URL format check (regex for http:// or https:// prefix), allowing SSRF to internal network services and cloud metadata endpoints.
Details
The vulnerability is an inconsistency between two URL-handling flows in MediaUploadService.
Vulnerable path (external-link):
MediaUploadV2Controller::externalLink() at src/Core/Content/Media/Api/MediaUploadV2Controller.php:66 takes a user-supplied url parameter and passes it to MediaUploadService::linkURL() at src/Core/Content/Media/Upload/MediaUploadService.php:134.
linkURL() calls getContentSizeFromValidExternalUrl($url) at line 159, which only validates via validateExternalUrl():
// src/Core/Content/Media/Upload/MediaUploadService.php:207-212
public static function validateExternalUrl(string $url): void
{
if (!preg_match('/^https?:\/\/.+/', $url)) {
throw MediaException::invalidUrl($url);
}
}
Then makes a server-side HEAD request with no IP filtering:
// src/Core/Content/Media/Upload/MediaUploadService.php:292-300
private function getContentSizeFromValidExternalUrl(string $url): int
{
$this->validateExternalUrl($url);
$headers = $this->httpClient->request('HEAD', $url)->getHeaders();
if (!\array_key_exists('content-length', $headers)) {
throw MediaException::fileNotFound($url);
}
return (int) $headers['content-length'][0];
}
Protected path (upload_by_url):
In contrast, uploadFromURL uses FileFetcher::fetchFromURL() which calls FileUrlValidator::isValid():
// src/Core/Content/Media/File/FileFetcher.php:64
if ($this->enableUrlValidation && !$this->fileUrlValidator->isValid($url)) {
throw MediaException::illegalUrl($url);
}
FileUrlValidator::isValid() resolves the hostname via gethostbyname() and validates the IP against private and reserved ranges using filter_var() with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. This protection is entirely absent from the linkURL flow.
Impact
An authenticated admin user can:
- Probe cloud metadata services — HEAD requests to
169.254.169.254reveal whether cloud metadata endpoints exist and leak content-length values - Scan internal networks — Differentiate open/closed/filtered ports on internal hosts (10.x, 172.16.x, 192.168.x) based on response timing and error types
- Leak internal service information — The
fileSizefield stored in the database reflects thecontent-lengthheader from internal services - Redirect-based escalation — Symfony HttpClient follows redirects by default (max_redirects=20), allowing an attacker-controlled external server to redirect the HEAD request to arbitrary internal destinations
Impact is limited to information disclosure via HEAD requests. The admin authentication requirement (PR:H) reduces exploitability, but in multi-tenant or compromised-credential scenarios this allows network reconnaissance from the server's perspective.
Recommended
Fix
Apply FileUrlValidator to the linkURL flow, consistent with the uploadFromURL flow. In MediaUploadService:
// src/Core/Content/Media/Upload/MediaUploadService.php
// Add constructor dependency:
private readonly FileUrlValidatorInterface $fileUrlValidator;
// In getContentSizeFromValidExternalUrl(), add IP validation:
private function getContentSizeFromValidExternalUrl(string $url): int
{
$this->validateExternalUrl($url);
if (!$this->fileUrlValidator->isValid($url)) {
throw MediaException::illegalUrl($url);
}
$headers = $this->httpClient->request('HEAD', $url)->getHeaders();
if (!\array_key_exists('content-length', $headers)) {
throw MediaException::fileNotFound($url);
}
return (int) $headers['content-length'][0];
}
Additionally, consider setting max_redirects: 0 on the HttpClient request to prevent redirect-based SSRF bypasses.
Affected products
1Patches
14dc2435a3867fix: Added more supported SVG elements and attributes to allowlist (#258)
5 files changed · +370 −2
RELEASE_INFO-6.7.md+2 −0 modified@@ -7,6 +7,8 @@ SVG uploads in the media subsystem are now validated against a strict passive SVG allowlist before persistence. Active content such as scripts, event handlers, processing instructions, external references, and URL-based references in attributes are rejected. +The default allowlist covers the W3C SVG2 presentation attribute set (https://www.w3.org/TR/SVG2/attindex.html#PresentationAttributes), ARIA accessibility attributes, the `lang` and `xml:lang` accessibility attributes, and the common safe structural elements `a`, `image`, `marker`, `metadata`, `switch`, `symbol`, and `view`. Anchor `href` / `xlink:href` references remain restricted to local document fragments (`#id`), so `javascript:`, `data:`, and remote URLs are rejected. Active content (scripts, event handlers, animations, foreign objects, processing instructions, DOCTYPEs, entities) and any external `url(...)` / `@import` references remain blocked regardless of the attribute that carries them. + The accepted SVG subset can be adjusted on installation level via `shopware.media.svg.allowed_elements`, `shopware.media.svg.allowed_attributes`, and `shopware.media.svg.allowed_reference_attributes` in `shopware.yaml`. ### `external-link` endpoint URL validation aligned with `upload-from-url`
src/Core/Framework/DependencyInjection/Configuration.php+59 −0 modified@@ -350,15 +350,19 @@ private function createMediaSection(): ArrayNodeDefinition ->arrayNode('allowed_elements') ->performNoDeepMerging() ->defaultValue([ + 'a', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'g', + 'image', 'line', 'lineargradient', + 'marker', 'mask', + 'metadata', 'path', 'pattern', 'polygon', @@ -368,30 +372,54 @@ private function createMediaSection(): ArrayNodeDefinition 'stop', 'style', 'svg', + 'switch', + 'symbol', 'text', 'title', 'tspan', 'use', + 'view', ]) ->scalarPrototype()->end() ->end() ->arrayNode('allowed_attributes') ->performNoDeepMerging() ->defaultValue([ + 'alignment-baseline', + 'aria-describedby', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'aria-roledescription', + 'baseline-shift', 'class', 'clip-path', + 'clip-rule', 'clippathunits', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'cursor', 'cx', 'cy', 'd', + 'direction', + 'display', 'dominant-baseline', 'dx', 'dy', 'fill', 'fill-opacity', 'fill-rule', + 'filter', + 'flood-color', + 'flood-opacity', 'font-family', 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-variant', 'font-weight', 'fx', 'fy', @@ -400,20 +428,39 @@ private function createMediaSection(): ArrayNodeDefinition 'height', 'href', 'id', + 'image-rendering', + 'lang', + 'letter-spacing', + 'lighting-color', + 'marker', + 'marker-end', + 'marker-mid', + 'marker-start', + 'markerheight', + 'markerunits', + 'markerwidth', 'mask', + 'mask-type', 'maskcontentunits', 'maskunits', 'offset', 'opacity', + 'orient', + 'overflow', + 'paint-order', 'patterncontentunits', 'patterntransform', 'patternunits', + 'pointer-events', 'points', 'preserveaspectratio', 'r', + 'refx', + 'refy', 'role', 'rx', 'ry', + 'shape-rendering', 'spreadmethod', 'stop-color', 'stop-opacity', @@ -422,19 +469,31 @@ private function createMediaSection(): ArrayNodeDefinition 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', + 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'text-anchor', + 'text-decoration', + 'text-overflow', + 'text-rendering', 'transform', + 'transform-origin', 'type', + 'unicode-bidi', + 'vector-effect', 'version', 'viewbox', + 'visibility', + 'white-space', 'width', + 'word-spacing', + 'writing-mode', 'x', 'x1', 'x2', 'xlink:href', + 'xml:lang', 'xml:space', 'xmlns', 'xmlns:xlink',
src/Core/Framework/Resources/config/packages/shopware.yaml+2 −2 modified@@ -391,8 +391,8 @@ shopware: enable: false pattern: '{mediaUrl}/{mediaPath}?width={width}&ts={mediaUpdatedAt}' svg: - allowed_elements: ["circle", "clippath", "defs", "desc", "ellipse", "g", "line", "lineargradient", "mask", "path", "pattern", "polygon", "polyline", "radialgradient", "rect", "stop", "style", "svg", "text", "title", "tspan", "use"] - allowed_attributes: ["class", "clip-path", "clippathunits", "cx", "cy", "d", "dominant-baseline", "dx", "dy", "fill", "fill-opacity", "fill-rule", "font-family", "font-size", "font-weight", "fx", "fy", "gradienttransform", "gradientunits", "height", "href", "id", "mask", "maskcontentunits", "maskunits", "offset", "opacity", "patterncontentunits", "patterntransform", "patternunits", "points", "preserveaspectratio", "r", "role", "rx", "ry", "spreadmethod", "stop-color", "stop-opacity", "stroke", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", "stroke-opacity", "stroke-width", "style", "text-anchor", "transform", "type", "version", "viewbox", "width", "x", "x1", "x2", "xlink:href", "xml:space", "xmlns", "xmlns:xlink", "y", "y1", "y2"] + allowed_elements: ["a", "circle", "clippath", "defs", "desc", "ellipse", "g", "image", "line", "lineargradient", "marker", "mask", "metadata", "path", "pattern", "polygon", "polyline", "radialgradient", "rect", "stop", "style", "svg", "switch", "symbol", "text", "title", "tspan", "use", "view"] + allowed_attributes: ["alignment-baseline", "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", "aria-roledescription", "baseline-shift", "class", "clip-path", "clip-rule", "clippathunits", "color", "color-interpolation", "color-interpolation-filters", "cursor", "cx", "cy", "d", "direction", "display", "dominant-baseline", "dx", "dy", "fill", "fill-opacity", "fill-rule", "filter", "flood-color", "flood-opacity", "font-family", "font-size", "font-size-adjust", "font-stretch", "font-style", "font-variant", "font-weight", "fx", "fy", "gradienttransform", "gradientunits", "height", "href", "id", "image-rendering", "lang", "letter-spacing", "lighting-color", "marker", "marker-end", "marker-mid", "marker-start", "markerheight", "markerunits", "markerwidth", "mask", "mask-type", "maskcontentunits", "maskunits", "offset", "opacity", "orient", "overflow", "paint-order", "patterncontentunits", "patterntransform", "patternunits", "pointer-events", "points", "preserveaspectratio", "r", "refx", "refy", "role", "rx", "ry", "shape-rendering", "spreadmethod", "stop-color", "stop-opacity", "stroke", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-opacity", "stroke-width", "style", "text-anchor", "text-decoration", "text-overflow", "text-rendering", "transform", "transform-origin", "type", "unicode-bidi", "vector-effect", "version", "viewbox", "visibility", "white-space", "width", "word-spacing", "writing-mode", "x", "x1", "x2", "xlink:href", "xml:lang", "xml:space", "xmlns", "xmlns:xlink", "y", "y1", "y2"] allowed_reference_attributes: ["href", "xlink:href"] dal:
tests/unit/Core/Content/Media/File/SvgContentValidatorTest.php+248 −0 modified@@ -364,6 +364,254 @@ public function testSvgWithLocalUrlReferenceInStyleElementPassesValidation(): vo } } + /** + * Regression coverage for real-world plugin payment icons (Apple Pay, card, + * PUI, SEPA) that previously broke after the strict SVG allowlist landed. + */ + public function testRealWorldPaymentIconsPassValidation(): void + { + $applePay = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" display="none"> + <path d="M5 10h10v2H5z"/> +</svg> +SVG); + + $card = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path clip-rule="evenodd" fill-rule="evenodd" d="M0 0h20v20H0z"/> +</svg> +SVG); + + try { + $this->validator->validate($applePay); + $this->validator->validate($card); + + static::assertSame('svg', $applePay->getFileExtension()); + static::assertSame('svg', $card->getFileExtension()); + } finally { + unlink($applePay->getFileName()); + unlink($card->getFileName()); + } + } + + public function testSvgWithSymbolAndMarkerPassesValidation(): void + { + $file = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <defs> + <symbol id="dot" viewBox="0 0 2 2"><circle cx="1" cy="1" r="1"/></symbol> + <marker id="arrow" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto" markerUnits="strokeWidth"> + <path d="M0 0L6 3L0 6z"/> + </marker> + </defs> + <use href="#dot" x="0" y="0"/> + <line x1="0" y1="10" x2="20" y2="10" stroke="black" marker-end="url(#arrow)"/> +</svg> +SVG); + + try { + $this->validator->validate($file); + + static::assertSame('svg', $file->getFileExtension()); + } finally { + unlink($file->getFileName()); + } + } + + public function testSvgWithImageReferencingLocalFragmentPassesValidation(): void + { + $file = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <defs> + <symbol id="icon" viewBox="0 0 1 1"><rect width="1" height="1"/></symbol> + </defs> + <image href="#icon" width="10" height="10"/> +</svg> +SVG); + + try { + $this->validator->validate($file); + + static::assertSame('svg', $file->getFileExtension()); + } finally { + unlink($file->getFileName()); + } + } + + public function testSvgWithAnchorAndLangAttributesPassesValidation(): void + { + $file = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" lang="en" xml:lang="en" viewBox="0 0 20 20"> + <defs> + <symbol id="dot"><circle cx="1" cy="1" r="1"/></symbol> + </defs> + <a href="#dot"><rect width="10" height="10"/></a> + <a xlink:href="#dot"><circle cx="15" cy="5" r="3"/></a> +</svg> +SVG); + + try { + $this->validator->validate($file); + + static::assertSame('svg', $file->getFileExtension()); + } finally { + unlink($file->getFileName()); + } + } + + #[DataProvider('anchorElementBypassAttemptsProvider')] + public function testAnchorElementDoesNotBypassReferenceChecks(string $svgContent): void + { + $file = $this->createSvgFile($svgContent); + + try { + $this->expectException(MediaException::class); + $this->expectExceptionMessage('SVG files with active content are not allowed.'); + + $this->validator->validate($file); + } finally { + unlink($file->getFileName()); + } + } + + public static function anchorElementBypassAttemptsProvider(): \Generator + { + yield 'anchor with external href' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><a href="https://attacker.invalid"><rect width="10" height="10"/></a></svg> +SVG, + ]; + + yield 'anchor with javascript pseudo scheme' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"><rect width="10" height="10"/></a></svg> +SVG, + ]; + + yield 'anchor with data uri' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="data:image/svg+xml;base64,PHN2Zy8+"><rect width="10" height="10"/></a></svg> +SVG, + ]; + } + + public function testSvgWithAriaAttributesPassesValidation(): void + { + $file = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="logo" aria-labelledby="t" aria-describedby="d" aria-hidden="false"> + <title id="t">Logo</title> + <desc id="d">An accessible logo</desc> + <rect width="10" height="10"/> +</svg> +SVG); + + try { + $this->validator->validate($file); + + static::assertSame('svg', $file->getFileExtension()); + } finally { + unlink($file->getFileName()); + } + } + + public function testSvgWithPresentationAttributesPassesValidation(): void + { + $file = $this->createSvgFile(<<<'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> + <rect width="10" height="10" + color="red" + visibility="visible" + overflow="hidden" + pointer-events="none" + shape-rendering="geometricPrecision" + vector-effect="non-scaling-stroke" + paint-order="stroke fill" + transform-origin="center" + stroke-miterlimit="4" + text-rendering="optimizeLegibility" + image-rendering="auto" + color-interpolation="sRGB" + color-interpolation-filters="linearRGB"/> +</svg> +SVG); + + try { + $this->validator->validate($file); + + static::assertSame('svg', $file->getFileExtension()); + } finally { + unlink($file->getFileName()); + } + } + + /** + * The expanded attribute allowlist must not weaken the universal value checks. + * Even on freshly allowed attributes, external url() refs and event handlers + * must still be rejected. + */ + #[DataProvider('newlyAllowedAttributesDoNotBypassValueChecksProvider')] + public function testNewlyAllowedAttributesDoNotBypassValueChecks(string $svgContent): void + { + $file = $this->createSvgFile($svgContent); + + try { + $this->expectException(MediaException::class); + $this->expectExceptionMessage('SVG files with active content are not allowed.'); + + $this->validator->validate($file); + } finally { + unlink($file->getFileName()); + } + } + + public static function newlyAllowedAttributesDoNotBypassValueChecksProvider(): \Generator + { + yield 'cursor with external url' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><rect cursor="url(https://attacker.invalid/cursor.png), auto"/></svg> +SVG, + ]; + + yield 'filter with external url' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><rect filter="url(https://attacker.invalid/filter)"/></svg> +SVG, + ]; + + yield 'marker-end with external url' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><line x1="0" y1="0" x2="10" y2="0" stroke="black" marker-end="url(https://attacker.invalid/arrow)"/></svg> +SVG, + ]; + + yield 'event handler on newly allowlisted-capable element (image)' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><image href="#x" onload="alert(1)"/></svg> +SVG, + ]; + + yield 'image element with external href' => [ + <<< 'SVG' +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg"><image href="https://attacker.invalid/leak.png"/></svg> +SVG, + ]; + } + public function testMerchantCanExtendAllowlistViaConfiguration(): void { $validator = $this->createValidator(
tests/unit/Core/Content/Media/File/SvgValidatorTestDefaults.php+59 −0 modified@@ -12,15 +12,19 @@ final class SvgValidatorTestDefaults { public const ALLOWED_ELEMENTS = [ + 'a', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'g', + 'image', 'line', 'lineargradient', + 'marker', 'mask', + 'metadata', 'path', 'pattern', 'polygon', @@ -30,27 +34,51 @@ final class SvgValidatorTestDefaults 'stop', 'style', 'svg', + 'switch', + 'symbol', 'text', 'title', 'tspan', 'use', + 'view', ]; public const ALLOWED_ATTRIBUTES = [ + 'alignment-baseline', + 'aria-describedby', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'aria-roledescription', + 'baseline-shift', 'class', 'clip-path', + 'clip-rule', 'clippathunits', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'cursor', 'cx', 'cy', 'd', + 'direction', + 'display', 'dominant-baseline', 'dx', 'dy', 'fill', 'fill-opacity', 'fill-rule', + 'filter', + 'flood-color', + 'flood-opacity', 'font-family', 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-variant', 'font-weight', 'fx', 'fy', @@ -59,20 +87,39 @@ final class SvgValidatorTestDefaults 'height', 'href', 'id', + 'image-rendering', + 'lang', + 'letter-spacing', + 'lighting-color', + 'marker', + 'marker-end', + 'marker-mid', + 'marker-start', + 'markerheight', + 'markerunits', + 'markerwidth', 'mask', + 'mask-type', 'maskcontentunits', 'maskunits', 'offset', 'opacity', + 'orient', + 'overflow', + 'paint-order', 'patterncontentunits', 'patterntransform', 'patternunits', + 'pointer-events', 'points', 'preserveaspectratio', 'r', + 'refx', + 'refy', 'role', 'rx', 'ry', + 'shape-rendering', 'spreadmethod', 'stop-color', 'stop-opacity', @@ -81,19 +128,31 @@ final class SvgValidatorTestDefaults 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', + 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'style', 'text-anchor', + 'text-decoration', + 'text-overflow', + 'text-rendering', 'transform', + 'transform-origin', 'type', + 'unicode-bidi', + 'vector-effect', 'version', 'viewbox', + 'visibility', + 'white-space', 'width', + 'word-spacing', + 'writing-mode', 'x', 'x1', 'x2', 'xlink:href', + 'xml:lang', 'xml:space', 'xmlns', 'xmlns:xlink',
Vulnerability mechanics
Root cause
"The linkURL flow in MediaUploadService lacks IP address validation for external URLs, unlike the uploadFromURL flow."
Attack vector
An authenticated administrator can send a crafted URL to the `/api/_action/media/external-link` endpoint. This URL, when processed by the `linkURL` flow, bypasses IP validation checks. The server then performs a server-side HEAD request to the provided URL without filtering internal IP addresses, enabling SSRF attacks against internal services or cloud metadata endpoints [ref_id=1].
Affected code
The vulnerability resides in the `MediaUploadService` class, specifically within the `linkURL` method called by `MediaUploadV2Controller::externalLink()`. The `getContentSizeFromValidExternalUrl` method at `src/Core/Content/Media/Upload/MediaUploadService.php` performs the vulnerable HEAD request without adequate IP validation [ref_id=1].
What the fix does
The recommended fix involves applying the `FileUrlValidator` to the `linkURL` flow within `MediaUploadService`, mirroring the protection already present in the `uploadFromURL` flow. This ensures that all external URLs processed by the service are validated against private and reserved IP ranges before a server-side request is made, preventing SSRF attacks [ref_id=1]. Additionally, setting `max_redirects` to 0 on the HTTP client can mitigate redirect-based bypasses.
Preconditions
- authThe attacker must be an authenticated administrator.
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
1- Shopware: Nine Vulnerabilities Disclosed, Including Privilege Escalation and XSSVypr Intelligence · Jun 4, 2026