VYPR
Medium severity4.1GHSA Advisory· Published Jun 4, 2026· Updated Jun 4, 2026

Shopware: SSRF in Media External-Link Endpoint Bypasses IP Validation

CVE-2026-48013

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:

  1. Probe cloud metadata services — HEAD requests to 169.254.169.254 reveal whether cloud metadata endpoints exist and leak content-length values
  2. 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
  3. Leak internal service information — The fileSize field stored in the database reflects the content-length header from internal services
  4. 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

1

Patches

1
4dc2435a3867

fix: Added more supported SVG elements and attributes to allowlist (#258)

https://github.com/shopware/shopwareMartin KrzykawskiMay 13, 2026Fixed in 6.7.10.1via release-tag
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

3

News mentions

1