Skip to content

CWE-830: Inclusion of Web Functionality from an Untrusted Source

Overview

Embedding third-party web functionality - a widget, a chat bubble, an ad tag, an analytics snippet, a mashup component - via a plain script include causes that code to run with the same origin and privileges as the including page itself: full access to the page's DOM, cookies, and any data on it, with no sandboxing. This is dangerous even when the third-party source is trustworthy at the time it's added, because a later compromise of that third party's domain, CDN, or build pipeline silently compromises every page that includes it, with no code change on the including site's part.

Relationship to Other CWEs

  • CWE-829 (Inclusion of Functionality from Untrusted Control Sphere) - the parent weakness, covering functionality pulled from any untrusted source: native libraries, package dependencies, dynamically loaded modules.
  • CWE-830 (this page) - the narrower case of third-party web functionality embedded into a page, where the risk is DOM and cookie access rather than native code execution.

OWASP Classification

A08:2025 - Software or Data Integrity Failures

Risk

High: Third-party web functionality that is hostile - malicious from the start, or compromised later - runs with full access to the including page's DOM and cookies. A compromised widget on a login page can rewrite the login form's submission target to an attacker-controlled endpoint, capturing credentials with no vulnerability in the site's own code. On any page, it can read session cookies or exfiltrate whatever data the page holds.

Remediation Steps

Core Principle: Never let externally-hosted web functionality run with unrestricted access to your page - verify its content hasn't changed since review, restrict both which origins can supply it and where it may send what it reads, and isolate it from the page's own data whenever it doesn't need that access.

Trace the Data Path

  • Source: A third-party script, stylesheet, or embedded widget loaded from another domain.
  • Sink: The browser executing that content within the including page's own origin.
  • Missing Controls: No verification that the fetched content matches what was reviewed (no integrity check), no restriction on which origins are allowed to supply executable content or receive what the page holds, and no isolation between the widget and the page's own DOM/cookies.

Verify Content Integrity with Subresource Integrity (Primary Defense for a Pinnable File)

Attach a cryptographic hash to every externally-hosted script or stylesheet reference. The browser refuses to execute the fetched content if its hash doesn't match, which covers the case where a source that was trustworthy when the reference was added is compromised afterwards: the reference fails closed rather than silently running whatever is served now.

// SECURE - pseudo-code, subresource integrity on an external script
include_script(
    url: "https://widget-provider.example.com/widget-2.4.1.js",
    integrity_hash: "sha384-<hash of the reviewed script content>",
    cross_origin: "anonymous"
)
// browser refuses to execute the script if the fetched bytes don't match the hash

cross_origin is not optional here. A browser will not apply an integrity check to a no-cors request, so a cross-origin reference carrying integrity and no crossorigin does not load at all, and the provider has to return Access-Control-Allow-Origin for the anonymous request to succeed. CWE-829 has the full mechanics, including the loads that take no markup attribute: fetch()'s integrity option, the .integrity property on a script created in JavaScript, and the integrity section of an import map for a dynamic import().

For the widgets in scope here, a hash usually leaves most of the running code uncovered. Integrity applies to the single request that carries the attribute. A chat bubble, ad tag or analytics snippet is usually a small loader whose job is to fetch the real payload at runtime, and the loader issues those second-stage requests itself with no integrity attribute on them - so pinning the snippet pins the part that was never going to change. The same providers ship rolling updates, which is the other half of the problem: a hash pinned to a versionless URL turns their next release into a blank widget rather than into a security event, and it is why many vendors ask you not to set one. Where the include is a specific versioned file, pin it, and prefer self-hosting a reviewed copy. Where it is a loader you do not control, treat the isolation and origin-restriction steps below as the controls that actually apply, and SRI as one that covers the first hop only.

Restrict Script Origins and Egress with a Content Security Policy

Configure a strict script-src (and style-src) allowlist so only explicitly approved domains can ever supply executable content to the page. This means a compromised or newly malicious source that isn't already on the allowlist cannot inject anything, even if a page template is later changed to reference it - and it is what catches the second-stage payload an integrity-pinned loader tries to pull in from somewhere new.

script-src bounds where code comes from, not where it may send what it reads, and for a widget that has already loaded from an approved origin the second question is the one that matters. It can exfiltrate the page's contents without loading any further script at all: fetch() or sendBeacon(), an image whose query string is the payload, a font or media load, a prefetch, or a form target rewritten to point elsewhere.

Naming those paths one directive at a time leaves the policy one directive short. Start from default-src 'none' and open only what the page needs, because every fetch directive that is not set falls back to it - img-src, connect-src, font-src, media-src, frame-src, worker-src and the rest. Two directives that matter here do not fall back, and a policy that assumes they do has a hole in exactly the shape it thinks it closed: form-action and base-uri are unset unless you set them, so under default-src 'none' a form can still post anywhere and an injected <base> can still retarget every relative URL on the page. Set both explicitly, and let the endpoints the widget legitimately talks to come from the provider rather than from guesswork.

Isolate Widgets That Don't Need Page Access (Primary Defense for a Loader)

Where the embedded functionality doesn't need to read or modify the including page's own data, load it inside a sandboxed iframe with a restrictive sandbox attribute instead of a same-origin script include. A sandboxed iframe cannot read the parent page's cookies or DOM, so a compromise of the widget's source no longer grants access to the host page's data - and unlike a hash, that holds for whatever the widget loads next.

One combination undoes the attribute, and it is the combination a widget usually asks for. sandbox="allow-scripts allow-same-origin" on a frame whose document you serve from your own origin returns the frame to that origin and lets it run script, at which point it can reach the parent, find its own <iframe> element and remove the sandbox attribute. Grant allow-scripts or allow-same-origin, not both; if the widget genuinely needs both, serve its document from an origin that is not yours, so allow-same-origin means the widget's origin rather than the application's.

Keep Sensitive Pages Free of Third-Party Functionality (Defense in Depth)

Keep third-party widgets, ads, and analytics off pages that handle credentials or payment data - login, checkout, password reset. Those are the pages where a compromised include does the most damage.

Test the Fix

  • Modify the referenced third-party script's content (or point the integrity hash at a different version) and confirm the browser blocks execution instead of silently loading the changed content.
  • Confirm the unmodified script still loads once the hash is attached. An integrity-checked reference that never loads at all is almost always a missing crossorigin attribute or a CDN that does not send Access-Control-Allow-Origin, not a wrong hash.
  • Attempt to load a script from an origin not on the CSP allowlist and confirm it is blocked.
  • From the browser console on the page, fetch() an origin the policy does not name and confirm the request is refused - script-src alone will let it through, so this is the bullet that tells you whether connect-src is actually set.
  • Do the same with an image beacon: new Image().src = 'https://not-on-the-policy.example/?x=1'. It exercises img-src rather than connect-src, needs no response to have exfiltrated, and is the path a policy that enumerates directives instead of starting from default-src 'none' most often leaves open.
  • Submit a form whose action points off-origin, and confirm it is blocked. form-action does not fall back to default-src, so this passes against a policy that looks complete.
  • Confirm a sandboxed widget iframe cannot read the parent page's cookies or modify the parent page's DOM.
  • Re-scan with the security scanner to confirm the finding is resolved.

Common Vulnerable Patterns

// VULNERABLE - pseudo-code, no integrity check, no origin restriction
include_script(url: "https://widget-provider.example.com/widget.js")
// Attack: widget-provider.example.com is compromised (or was malicious from
// the start); the injected script rewrites the page's login form action to
// an attacker-controlled endpoint, capturing every submitted credential

Why this is vulnerable: a script include is not an embed, it is a delegation. The fetched code runs in the including page's origin with the same authority as code the application wrote - full access to the DOM, to cookies not marked HttpOnly, to any token in local storage, and to the page's own network privileges. There is no sandbox and no reduced permission set to configure, so the security of the page is now the security of the provider's build pipeline, their CDN, their domain registration, and every dependency they in turn include.

That trust is also continuous rather than one-off. The URL names a location, not a version, so what is served can change at any moment. Reviewing the widget's code says nothing about what will run tomorrow, and a compromise of the provider takes effect on the next page load with no deployment on your side.

Secure Patterns

// SECURE - pseudo-code, integrity-checked and origin-restricted
set_content_security_policy(
    default_src: ["'none'"],      // anything not named below is refused, including
                                  // img/font/media/prefetch - the no-script egress paths
    script_src:  ["'self'", "https://widget-provider.example.com"],
    connect_src: ["'self'", "https://widget-provider.example.com"],
    img_src:     ["'self'"],      // a fallback directive: omitting it would defer to
                                  // default_src. Setting it supersedes that, and makes
                                  // the beacon path a deliberate decision rather than
                                  // an inherited one
    style_src:   ["'self'"],
    form_action: ["'self'"],      // does NOT inherit from default_src
    base_uri:    ["'self'"]       // nor does this
)
include_script(
    url: "https://widget-provider.example.com/widget-2.4.1.js",
    integrity_hash: "sha384-<hash of the reviewed script content>",
    cross_origin: "anonymous"
)

Why this works: The integrity hash means the browser only ever executes the exact content that was reviewed when the reference was added, so a later compromise of the provider produces a blocked script rather than code running in the page's context. The pin holds here because the URL names a specific version, so the pinned bytes are the ones the provider intends to keep serving, and because cross_origin is present, without which a cross-origin request is never integrity-checked at all. The CSP is the boundary that survives what the hash misses. script_src refuses any further code the script fetches from an origin nobody approved. default_src: 'none' bounds egress, and it is set that way rather than by listing endpoints because the ways a hostile script can post data out - an image beacon, a font, a prefetch, a media element - outnumber the ones anybody remembers to enumerate; connect_src and img_src then re-open the two the page actually uses. form_action and base_uri are listed separately because neither inherits from default_src: without them the deny-by-default policy still permits a rewritten form target and an injected <base>. Neither control makes the provider trustworthy, which is why a page handling credentials or payment data should not carry the include in the first place.

Additional Resources