This is less an unlocked front door than a poisoned shipment that only hurts you after it gets unpacked
CVE-2026-27970 is an Angular i18n XSS in ICU message handling: malicious HTML embedded in translated content can survive into the rendered app and execute JavaScript in the app's origin. Authoritative ranges are broad on paper but narrow in practice: @angular/core is affected at <=18.2.14, >=19.0.0-next.0 <19.2.19, >=20.0.0-next.0 <20.3.17, >=21.0.0-next.0 <21.1.6, and >=21.2.0-next.0 <21.2.0, with fixes in 19.2.19, 20.3.17, 21.1.6, and 21.2.0.
The vendor baseline of MEDIUM 6.1 is still too generous for most enterprise patch queues because this is not an arbitrary-user, internet-triggerable XSS. The attacker has to tamper with a translation artifact or the localization supply chain first, the app must actually use Angular i18n plus ICU messages, and a sane CSP or Trusted Types policy can blunt or stop the payoff; those are heavy real-world friction points.
4 steps from start to impact.
Compromise the translation pipeline
- Attacker can alter translation files or the process that delivers them
- Victim organization consumes third-party or otherwise untrusted translation content
- This is already a prior-compromise condition, not first-hop internet reachability
- Many enterprises keep localization assets in version control with review trails
- Artifact signing, branch protection, and vendor access controls can break the chain early
Plant a malicious ICU payload
fix(core): block creation of sensitive URI attributes from ICU messages in Angular PR #67183, which tells you the vulnerable path is specific, not a broad template-compilation failure.- Application uses Angular i18n
- At least one affected ICU message exists and is rendered at runtime
- A lot of Angular apps do not use i18n at all
- Even among i18n users, not every app uses exploitable ICU constructs in rendered paths
- Static SCA may flag the package version but cannot prove the feature is in use
@angular/core versions, but not whether ICU messages are present or reachable. Template and translation-file review is required.Ship the poisoned translation into production
- Compromised translation content is accepted into a build or runtime translation asset store
- Deployment promotes the affected Angular version with that content intact
- Release approvals, content QA, and localization review often inspect visible string changes
- Build pipelines may pin hashes or compare translation artifacts between releases
Render the ICU message in a browser session
- A user visits the affected page
- The page renders the compromised ICU message
- CSP/Trusted Types do not block the injected execution path
- User interaction is required
- Strict CSP can block inline or unauthorized script execution
- Trusted Types reduces the browser-side abuse surface for DOM sinks
The supporting signals.
| In-the-wild status | No authoritative public evidence of active exploitation found in CISA KEV or the vendor advisory as of this assessment. |
|---|---|
| Proof-of-concept availability | I found patch material and advisory detail, but no mainstream public exploit repo or Metasploit-style weaponization. The closest technical breadcrumb is Angular PR #67183. |
| EPSS | 0.00055 (~0.055%), roughly low-teens to high-teens percentile depending on daily feed timing; that is background noise, not pressure from observed attacker interest. |
| KEV status | Not listed in CISA's Known Exploited Vulnerabilities Catalog. No due date, no federal exploitation signal. |
| CVSS vector reality check | Vendor/NVD CVSS v3.1 is AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N at 6.1 MEDIUM. The overstatement is AV:N: technically true, operationally misleading, because exploitation really starts with translation-file compromise. |
| Affected versions | <=18.2.14, 19.0.0-next.0 through 19.2.18, 20.0.0-next.0 through 20.3.16, 21.0.0-next.0 through 21.1.5, and 21.2.0-next.0 through 21.2.0-rc.0 per the GitHub advisory and OSV. |
| Fixed versions | Upgrade to 19.2.19, 20.3.17, 21.1.6, or 21.2.0. Ubuntu and Debian trackers list the CVE, but this is primarily an application dependency issue rather than a typical distro-network-service exposure. |
| Exposure/scanning reality | Shodan/Censys-style external exposure counts are not meaningful here because @angular/core is a browser-side library, not an internet-facing daemon. Reachability must be established through SBOM/SCA plus code/search for Angular i18n and ICU usage. |
| Disclosure date | Public disclosure clustered around 2026-02-25 to 2026-02-26 depending on source timezone: GitHub shows publication on 2026-02-25, while NVD/Snyk record publication/disclosure on 2026-02-26. |
| Reporter / originating source | Public CVE source is GitHub/GHSA for angular/angular. The public advisory does not clearly credit an external reporter. |
noisgate verdict.
The decisive factor is that exploitation requires prior compromise of the translation supply chain or translation files before the Angular bug matters. That makes this a post-compromise amplifier for a subset of multilingual Angular apps, not a broadly reachable initial-access issue.
Why this verdict
- Requires a prior foothold in the translation pipeline: attacker position is effectively supply-chain, insider, or already-compromised CI/repo access, which implies the attacker is past the hardest part already. That is a major downward adjustment from the vendor's network-reachable framing.
- Feature-gated exposure: only apps using Angular i18n, using ICU messages, and rendering those messages are in scope. That sharply cuts the real exposed population compared with raw
@angular/coreinstall counts. - Modern browser defenses can stop the payoff: strict CSP and Trusted Types are specifically named by Angular as mitigations. Those are not universal, but where present they add another choke point after the package-level flaw.
Why not higher?
It is not higher because there is no evidence of broad in-the-wild exploitation, no unauthenticated remote trigger from the open internet, and no server-side compromise path. The blast radius is also application-specific: a poisoned translation affects the app that shipped it, not every Angular host on the network by default.
Why not lower?
It is not IGNORE because successful exploitation still yields real same-origin JavaScript execution with credential theft and user-session abuse potential. If you run multilingual customer-facing Angular apps and outsource translations, the supply-chain prerequisite is plausible enough that the issue deserves inventorying and a normal patch cycle.
What to do — in priority order.
- Enforce strict CSP and Trusted Types — Apply this first where you control the front-end hosting stack, because Angular explicitly calls out both controls as mitigations for this issue. For a LOW verdict there is no SLA; treat it as backlog hygiene, but prioritize any internet-facing multilingual apps before low-value internal portals.
- Gate translation artifacts in CI — Require review, provenance checks, and ideally signatures or hash pinning for XLIFF/XTB and related localization assets so a compromised vendor or repo cannot silently inject content. For a LOW verdict there is no SLA; add it to normal SDLC hardening work rather than emergency change windows.
- Inventory Angular i18n and ICU usage — Do not burn cycles patching every Angular project blindly. Find the projects that actually use i18n and ICU-rendered messages, then prioritize upgrades there first; for LOW there is no SLA, so this is targeted backlog triage.
- Upgrade affected Angular branches — Move impacted apps to
19.2.19,20.3.17,21.1.6, or21.2.0depending on branch. For LOW there is no SLA and this should ride the next sane application maintenance cycle after the exposed multilingual apps are identified.
- A perimeter WAF does not meaningfully solve this because the dangerous payload is typically embedded in shipped translation content, not delivered as a classic inbound HTTP attack string.
- Endpoint EDR on developer laptops will not reliably prevent browser-side same-origin JavaScript execution in end-user sessions after a bad translation has shipped.
- Package-only SCA is insufficient by itself because it flags version exposure but cannot tell you whether the app uses Angular i18n, ICU messages, or has CSP/Trusted Types in place.
Crowdsourced verification payload.
Run this on a source checkout, build workspace, or CI runner that has access to the application's package.json, lockfile, or node_modules. Invoke it as python3 check_cve_2026_27970.py /path/to/project; no admin rights are needed. It outputs VULNERABLE, PATCHED, or UNKNOWN and exits 0, 1, or 2 respectively.
#!/usr/bin/env python3
# check_cve_2026_27970.py
# Detects whether a project appears vulnerable to CVE-2026-27970 based on @angular/core version.
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
import json
import os
import re
import sys
from typing import Optional, Tuple
PKG = '@angular/core'
def normalize(v: str) -> str:
v = v.strip()
v = re.sub(r'^[\^~<>= ]+', '', v)
if v.startswith('v'):
v = v[1:]
return v.strip()
def parse_version(v: str) -> Optional[Tuple[int, int, int, str]]:
v = normalize(v)
m = re.match(r'^(\d+)\.(\d+)\.(\d+)(?:[-+]([A-Za-z0-9.-]+))?$', v)
if not m:
return None
major = int(m.group(1))
minor = int(m.group(2))
patch = int(m.group(3))
suffix = m.group(4) or ''
return (major, minor, patch, suffix)
def cmp_ver(a: Tuple[int, int, int, str], b: Tuple[int, int, int, str]) -> int:
for i in range(3):
if a[i] < b[i]:
return -1
if a[i] > b[i]:
return 1
# Stable release is newer than prerelease with same numeric version
if a[3] == b[3]:
return 0
if a[3] == '':
return 1
if b[3] == '':
return -1
return -1 if a[3] < b[3] else (1 if a[3] > b[3] else 0)
def in_range(vs: str) -> Optional[bool]:
v = parse_version(vs)
if not v:
return None
# Vulnerable ranges from GHSA/OSV:
# <=18.2.14
# >=19.0.0-next.0 <19.2.19
# >=20.0.0-next.0 <20.3.17
# >=21.0.0-next.0 <21.1.6
# >=21.2.0-next.0 <21.2.0
if v[0] <= 18:
return cmp_ver(v, parse_version('18.2.14')) <= 0
if v[0] == 19:
return cmp_ver(v, parse_version('19.0.0-next.0')) >= 0 and cmp_ver(v, parse_version('19.2.19')) < 0
if v[0] == 20:
return cmp_ver(v, parse_version('20.0.0-next.0')) >= 0 and cmp_ver(v, parse_version('20.3.17')) < 0
if v[0] == 21:
if cmp_ver(v, parse_version('21.0.0-next.0')) >= 0 and cmp_ver(v, parse_version('21.1.6')) < 0:
return True
if cmp_ver(v, parse_version('21.2.0-next.0')) >= 0 and cmp_ver(v, parse_version('21.2.0')) < 0:
return True
return False
return False
def read_json(path: str):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return None
def find_version_in_package_json(root: str) -> Optional[str]:
data = read_json(os.path.join(root, 'package.json'))
if not data:
return None
for section in ('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'):
deps = data.get(section, {})
if isinstance(deps, dict) and PKG in deps:
return str(deps[PKG])
return None
def find_version_in_package_lock(root: str) -> Optional[str]:
for name in ('package-lock.json', 'npm-shrinkwrap.json'):
data = read_json(os.path.join(root, name))
if not data:
continue
packages = data.get('packages')
if isinstance(packages, dict):
nm_key = 'node_modules/@angular/core'
if nm_key in packages and isinstance(packages[nm_key], dict):
ver = packages[nm_key].get('version')
if ver:
return str(ver)
deps = data.get('dependencies')
if isinstance(deps, dict) and PKG in deps and isinstance(deps[PKG], dict):
ver = deps[PKG].get('version')
if ver:
return str(ver)
return None
def find_version_in_node_modules(root: str) -> Optional[str]:
pkg_json = os.path.join(root, 'node_modules', '@angular', 'core', 'package.json')
data = read_json(pkg_json)
if data and data.get('version'):
return str(data['version'])
return None
def main():
root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
if not os.path.isdir(root):
print('UNKNOWN: target path does not exist or is not a directory')
sys.exit(2)
version = (
find_version_in_package_lock(root)
or find_version_in_node_modules(root)
or find_version_in_package_json(root)
)
if not version:
print('UNKNOWN: could not determine @angular/core version from lockfile, node_modules, or package.json')
sys.exit(2)
verdict = in_range(version)
if verdict is None:
print(f'UNKNOWN: found @angular/core version {version!r} but could not parse it')
sys.exit(2)
elif verdict:
print(f'VULNERABLE: detected @angular/core {version}')
sys.exit(1)
else:
print(f'PATCHED: detected @angular/core {version}')
sys.exit(0)
if __name__ == '__main__':
main()
If you remember one thing.
Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.