Vulnerability DatabaseGHSA-pxwg-mv9c-h422

GHSA-pxwg-mv9c-h422: 
JavaScript vulnerability analysis and mitigation

Summary

In @openeudi/openid4vp 0.18.0, verifyPresentation returns valid: true for ten presentations that a public set of negative test vectors expects a verifier to refuse. Seven of the ten vectors quote the sentence of the specification they rest on; three cite ISO/IEC 18013-5 without a quotation (see the note below item 9). Five are SD-JWT VC presentations and five are mdoc DeviceResponses. The same call accepts all five controls of the set and all 32 repaired twins of the mdoc vectors, and it refuses the other 44 of the 54 must-refuse vectors. The inputs and the call are therefore sound, and the ten acceptances are specific to these vectors. Two of the ten change what the caller is told (items 1 and 2). For the other eight the measured effect is the verdict alone.

What was measured

  • The release: tag v0.18.0, commit 21839bcc0acd674d8c70e4e4ea8ee918d1b4bbf6. main is identical to the tag today (0 commits apart), and npm's latest is 0.18.0.
  • Two routes with the same result on every vector: the package as published on npm, and a local build of the tagged commit. The built dist/index.js is byte-equal to the published one (sha256 55a63b0f52faa97ea1f5e65fed153a8993103285f3dd05092dbb531e738abf87).
  • The verdicts were measured in two separate runs, which agree on every vector. The returned claims, the clock check under item 1 and the run with the tightest options were measured in one of them, on both routes.
  • Node v24.22.2. The entry point is verifyPresentation, with each file's own trust anchors or issuer JWKs, nonce and audience, the SessionTranscript from the library's two OpenID4VP builders, and the clock set to the instant each file is signed for.
  • The vectors: vectors.json 0.4.0 (37 vectors) and mdoc-vectors.json 0.1.0 (34 vectors, 32 of them with a repaired twin), both at commit 2fd4edcf0902fb708409d8fc9b54ed7136b7567b of the public repository EgorKhaklin/polaris-id, directory lab/interop/negative-vectors/.
  • No documented option turns a refusal on for any of the ten. With clockSkewTolerance: 0 and allowedAlgorithms: ['ES256'], plus requireKeyBinding: true for SD-JWT VC and expectedDocType for mdoc, all ten are still accepted.
  • The run that measured the claims, the clock check and the options had no network at run time. No deployed system was touched: the library was called on the two vector files, whose URLs name reserved .example hosts only. Each vector carries a class. spec: a sentence of the cited text requires the refusal. spec-implied: the cited sentence binds the issuer or the wallet, or a definition implies the refusal; the mdoc file adds "or the clause is ISO-only, its text not read", which is the case for items 7, 8 and 9. The class is given with each item, because for a spec-implied vector the verifier's duty to refuse is a reading and yours to judge.

Preconditions, for all ten

In eight of the ten the malformed part is inside what the issuer signs (the SD-JWT payload, its _sd digests, the JOSE header, or the IssuerAuth structure). Such a credential comes from an issuer that does not follow the specification. A holder cannot make one out of a well-formed credential. Items 8 and 9 are different: the malformed part is in the device-signed half, which the holder's wallet produces. In both, the library ignores the extra data and does not return it.

The items, ordered by what the caller loses

Line numbers are at commit 21839bcc. "Returns" is the measured result at 0.18.0.

1. disclosed-exp: an expiry carried in a Disclosure is never checked

  • Vector: disclosed-exp (class spec-implied). Clause, SD-JWT VC draft-19 s2.2.2.3: "The following registered JWT claims, including any of their sub-claims, when used in the SD-JWT component of an SD-JWT VC, MUST NOT be included in Disclosures and therefore MUST NOT be selectively disclosed"
  • Input: exp travels in a Disclosure instead of the payload.
  • Returns: valid: true and the same three claims as the control accept-x5c.
  • Past the verdict: with the clock one day after the exp value (1822348800), the control, which carries that value in the signed payload, is refused ("Issuer JWT signature verification failed"). This vector, which carries the same value in a Disclosure, is accepted once kbJwtMaxAge is widened. With the default kbJwtMaxAge it is refused at that clock for the age of the fixture's KB-JWT alone. The widened option stands in for a KB-JWT signed at presentation time; a freshly signed one was not tested.
  • Cause: src/parsers/sd-jwt.parser.ts:280. jwtVerify on the issuer JWT is the only expiry check, and it reads exp from the signed payload. No line refuses a Disclosure named exp, and :139-146 leave exp out of the returned claims, so the caller does not see the value either.

2. claim-overwrite: a Disclosure replaces a claim the signed payload holds in the clear

  • Vector: claim-overwrite (class spec). Clause, RFC 9901 s7.1 step 3.c.ii.3: "If the claim name already exists at the level of the _sd key, the SD-JWT MUST be rejected."
  • Input: a Disclosure names a claim the payload already holds in the clear.
  • Returns: valid: true with given_name: "Erika", the Disclosure's value. The signed payload holds given_name: "Max" in the clear.
  • Cause: src/parsers/sd-jwt.parser.ts:412-431 decodes each Disclosure and keeps its hash, length and value; the claim name is not read. :573-574 then hands payload and Disclosures to getClaims of @sd-jwt/decode 0.19.0, where the disclosed value is written over the clear one (dist/index.mjs:176 and :181).

3. disclosed-iss: an issuer identifier carried in a Disclosure is accepted

  • Vector: disclosed-iss (class spec-implied). Clause: SD-JWT VC draft-19 s2.2.2.3, the sentence quoted under item 1.
  • Input: iss travels in a Disclosure instead of the payload.
  • Returns: valid: true, with the same claims and the same issuer object as the control accept-jwks.
  • Cause: src/parsers/sd-jwt.parser.ts:328 reads iss from the signed payload only, as an empty string when it is absent, and no line refuses a Disclosure named iss.

4. disclosure-named-sd: a Disclosure whose claim name is _sd is accepted (a dependency's published advisory)

  • Vector: disclosure-named-sd (class spec). Clause, RFC 9901 s7.1 step 3.c.ii.2: "If the claim name is _sd or ..., the SD-JWT MUST be rejected."
  • Input: a Disclosure whose claim name is _sd.
  • Returns: valid: true and the same claims as the control accept-x5c. The _sd entry itself is not returned (:139 drops keys that start with _sd).
  • Cause: the name is not read at src/parsers/sd-jwt.parser.ts:412-431, and getClaims of @sd-jwt/decode 0.19.0 accepts it.
  • This one is public in the dependency. GHSA-f9j6-8p6x-r9j6 (CVE-2026-62374), published 2026-11-02 for @sd-jwt/decode 0.19.0 and earlier, lists "Reserved claim name _sd accepted" among its related findings. The advisory gives no patched release of @sd-jwt/decode, which npm marks as deprecated ("Merged into @sd-jwt/core (>= 0.20.0)"), and gives @sd-jwt/core 0.20.0 as patched. Whether 0.20.0 refuses this vector was not tested. What is reported here is this verifier's measured result at 0.18.0, whose package.json on main names @sd-jwt/decode ^0.19.0.
  • The main subject of that advisory, a Disclosure that the signed payload does not list, does not reach callers of this library: sd-jwt.parser.ts checks every Disclosure against the signed digests itself, and the run refuses those vectors ("Disclosure hash mismatch").

5. md-issuerauth-crit-unknown: an unknown critical header parameter in IssuerAuth is ignored

  • Vector: md-issuerauth-crit-unknown (class spec-implied). Clause, RFC 9052 3.1: "This header parameter is used to indicate which protected header parameters an application that is processing a message is required to understand."
  • Input: IssuerAuth's protected header lists in crit a private-use label (-65537) that it carries and no verifier understands.
  • Returns: valid: true and the same claims as the vector's repaired twin.
  • Cause: src/crypto/cose-sign1.ts knows two header labels (:18 alg, :23 x5chain), and :73 reads alg alone. crit is read nowhere in cose-sign1.ts, device-auth.ts or mdoc.parser.ts.

6. md-issuerauth-dup-label: a protected header with a repeated label is accepted

  • Vector: md-issuerauth-dup-label (class spec). Clause, RFC 9052 3: "Labels in each of the maps MUST be unique. When processing messages, if a label appears multiple times, the message MUST be rejected as malformed."
  • Input: IssuerAuth's protected header map carries the alg label twice (a2 01 26 01 26), signed as those bytes.
  • Returns: valid: true and the same claims as the repaired twin.
  • Cause: src/crypto/cose-sign1.ts:64 decodes the protected header into a Map, where a repeated label collapses into one entry. No line checks that the labels are unique. The result is the same with cbor-x 1.6.4 (the lockfile) and 1.6.9 (a fresh install).

7. md-mso-not-tag24: an IssuerAuth payload that is a bare MSO is accepted

  • Vector: md-mso-not-tag24 (class spec-implied). Clause as the vector cites it: ISO/IEC 18013-5, MobileSecurityObjectBytes, with no quotation and the clause number inferred (see the note below item 9).
  • Input: IssuerAuth's payload is the bare MSO, not MobileSecurityObjectBytes (tag 24).
  • Returns: valid: true and the same claims as the repaired twin.
  • Cause: src/crypto/mso.ts:58-63 decodes the payload, unwraps it when it is tag 24, and uses it as it is when it is already a map. Nothing refuses the bare form before :84.

8. md-devicesigned-unauthorized-ns: a device-signed element outside the MSO's KeyAuthorizations is accepted

  • Vector: md-devicesigned-unauthorized-ns (class spec-implied). Clause as the vector cites it: ISO/IEC 18013-5 9.1.3.4 and 12.3.4, with no quotation and the numbers inferred.
  • Input: DeviceSigned carries an element in a namespace the MSO's KeyAuthorizations do not list.
  • Returns: valid: true. namespacedClaims holds the issuer-signed name space alone (three elements): the device-signed element is not handed to the caller (src/parsers/mdoc.parser.ts:453 maps issuer-signed items only).
  • Cause: src/crypto/device-auth.ts:158-160 feeds the device-signed name spaces into the DeviceAuthentication bytes and nowhere else. No line compares them with the MSO's KeyAuthorizations (keyAuthorizations has no match in src/, in any letter case).

9. md-devicesig-not-detached: a DeviceSignature that carries its payload is accepted

  • Vector: md-devicesig-not-detached (class spec-implied). Clause as the vector cites it: ISO/IEC 18013-5, with no quotation and the clause number inferred.
  • Input: the DeviceSignature carries its payload (DeviceAuthenticationBytes) instead of detaching it.
  • Returns: valid: true and the same claims as the repaired twin.
  • Cause: src/crypto/device-auth.ts:138-139 reads elements 0 and 3 of the DeviceSignature. Element 2, the payload, is never read, and the signature is verified over the DeviceAuthentication bytes the verifier builds itself (:160). The carried payload is ignored, not trusted. A note on items 7, 8 and 9: their vectors cite ISO/IEC 18013-5 without quoting it. The ISO text was not read for these vectors, and the clause numbers are inferred. If the text reads otherwise, these three are not findings.

10. x5c-includes-anchor: an x5c that carries the trust anchor's own certificate is accepted (a conformance note)

  • Vector: x5c-includes-anchor (class spec-implied). Clause, HAIP 1.0 s6.1.1: "The X.509 certificate of the trust anchor MUST NOT be included in the x5c JOSE header of the SD-JWT VC."
  • Input: x5c carries the trust anchor's own certificate.
  • Returns: valid: true and the same claims as the control accept-x5c.
  • Cause: src/parsers/sd-jwt.parser.ts:342-351 passes the x5c entries after the first as untrusted path candidates, and nothing refuses one that is the anchor's own certificate.
  • The clause binds the sender. No trust is gained that the trust store did not already give, so this is a conformance note and not a finding of its own.

How to reproduce

About a minute, with no network after the install and the two downloads.

mkdir repro && cd repro
npm init -y
npm install --ignore-scripts @openeudi/openid4vp@0.18.0
B=https://raw.githubusercontent.com/EgorKhaklin/polaris-id/2fd4edcf0902fb708409d8fc9b54ed7136b7567b/lab/interop/negative-vectors
curl -sSfLO $B/vectors.json
curl -sSfLO $B/mdoc-vectors.json
shasum -a 256 vectors.json mdoc-vectors.json

# 49d92709c514ea26443d08843ba64f3ec37b69c07902e7040f7cfc7d2958f874  vectors.json

# 2525e629f92adad0a4a44649f7868eb9ecab84bc60135a126862f118a28857de  mdoc-vectors.json
node repro.mjs

repro.mjs:

// repro.mjs: every vector of vectors.json and mdoc-vectors.json through verifyPresentation.
// Prints each vector whose verdict differs from the file's expected one, then one clock check.
// Run: node repro.mjs   (in a directory holding the two files and @openeudi/openid4vp; no network)
import { readFileSync } from 'node:fs';
import * as oe from '@openeudi/openid4vp';
const RealDate = Date;
function setNow(ms) {
    // the vectors are signed for a fixed instant, so the clock is set to it
    globalThis.Date = class extends RealDate {
        constructor(...a) {
            if (a.length) super(...a);
            else super(ms);
        }
        static now() {
            return ms;
        }
    };
}
async function verdict(call) {
    try {
        return await call();
    } catch (e) {
        return { valid: false, parsed: { error: String(e?.message) } };
    }
}
const rows = [];
const sd = JSON.parse(readFileSync('vectors.json', 'utf8'));
const s = sd.settings;
const sdQuery = { credentials: [{ id: 'c1', format: 'dc+sd-jwt', meta: { vct_values: [s.vct] } }] };
const sdTrust = (v) =>
    v.trust === 'jwks' ? { trustedIssuerJwks: s.issuer_jwks } : { trustStore: new oe.StaticTrustStore(s.trust_anchors_pem) };
for (const v of sd.vectors) {
    setNow(s.now * 1000);
    rows.push([v, await verdict(() => oe.verifyPresentation(v.presentation, sdQuery, { nonce: s.nonce, audience: s.audience, ...sdTrust(v) }))]);
}
const md = JSON.parse(readFileSync('mdoc-vectors.json', 'utf8'));
for (const v of md.vectors) {
    setNow(RealDate.parse(v.at));
    const i = v.inputs;
    const cred = { id: 'm1', format: 'mso_mdoc', meta: { doctype_value: i.doctype_value } };
    if (i.claims) cred.claims = i.claims;
    if (i.claim_sets) cred.claim_sets = i.claim_sets;
    rows.push([
        v,
        await verdict(async () =>
            oe.verifyPresentation(new Uint8Array(Buffer.from(v.response, 'base64url')), { credentials: [cred] }, {
                nonce: i.nonce,
                mdocSessionTranscript: String(i.response_mode).startsWith('dc_api')
                    ? await oe.buildOpenID4VPDCAPIHandoverSessionTranscript({ origin: i.origin, nonce: i.nonce, verifierEncryptionJwk: i.encryption_jwk })
                    : await oe.buildOpenID4VPHandoverSessionTranscript({ clientId: i.client_id, nonce: i.nonce, responseUri: i.response_uri, verifierEncryptionJwk: i.encryption_jwk }),
                trustStore: new oe.StaticTrustStore(v.trust_anchors_pem),
            })
        ),
    ]);
}
console.log(`@openeudi/openid4vp ${oe.VERSION}, node ${process.version}`);
let controls = 0, controlsOk = 0, must = 0, refused = 0;
for (const [v, r] of rows) {
    const got = r.valid === true ? 'accept' : 'reject';
    const isControl = v.class === 'control';
    // must-refuse: a cited sentence requires or implies the refusal; the status-list layer needs a status policy and is left out
    const isMust = (v.class === 'spec' || v.class === 'spec-implied') && v.layer !== 'status-list';
    if (isControl) { controls += 1; controlsOk += got === 'accept' ? 1 : 0; }
    if (isMust) { must += 1; refused += got === 'reject' ? 1 : 0; }
    if ((isControl || isMust) && got !== v.expected) {
        const extra = v.id === 'claim-overwrite' ? `; returned given_name ${JSON.stringify(r.parsed?.claims?.given_name)}` : '';
        console.log(`${v.id}: expected ${v.expected}, got ${got}${extra}`);
    }
}
console.log(`controls accepted ${controlsOk} of ${controls}; must-refuse vectors refused ${refused} of ${must}`);
// The clock one day after the exp value. accept-x5c carries exp in the signed payload, disclosed-exp in a Disclosure.
// kbJwtMaxAge is widened so that the age of the fixture's KB-JWT is not what decides.
const control = sd.vectors.find((v) => v.id === 'accept-x5c');
const exp = JSON.parse(Buffer.from(control.presentation.split('~')[0].split('.')[1], 'base64url').toString('utf8')).exp;
for (const id of ['accept-x5c', 'disclosed-exp']) {
    const v = sd.vectors.find((x) => x.id === id);
    setNow((exp + 86400) * 1000);
    const r = await verdict(() =>
        oe.verifyPresentation(v.presentation, sdQuery, { nonce: s.nonce, audience: s.audience, kbJwtMaxAge: 10 * 365 * 86400, ...sdTrust(v) })
    );
    console.log(`clock one day after exp ${exp}: ${id} -> ${r.valid === true ? 'accept' : 'reject'}${r.valid === true ? '' : ` (${r.parsed?.error ?? ''})`}`);
}

Output at 0.18.0:

@openeudi/openid4vp 0.18.0, node v24.22.2
claim-overwrite: expected reject, got accept; returned given_name "Erika"
disclosed-exp: expected reject, got accept
disclosed-iss: expected reject, got accept
disclosure-named-sd: expected reject, got accept
x5c-includes-anchor: expected reject, got accept
md-devicesig-not-detached: expected reject, got accept
md-devicesigned-unauthorized-ns: expected reject, got accept
md-issuerauth-crit-unknown: expected reject, got accept
md-issuerauth-dup-label: expected reject, got accept
md-mso-not-tag24: expected reject, got accept
controls accepted 5 of 5; must-refuse vectors refused 44 of 54
clock one day after exp 1822348800: accept-x5c -> reject (Issuer JWT signature verification failed)
clock one day after exp 1822348800: disclosed-exp -> accept

The entry of each of the ten in the two files names what was changed and cites its clause. Each of the five mdoc vectors above carries a repaired twin (repaired.response in its entry) with that point corrected; the script does not run the twins.

Severity

Left to you. The preconditions are stated above and with each item.

Three things for you

  1. Your condition 4 in issue 80 is kept. That this library accepts these ten is in no pull request, no issue and no public text, and it stays that way until you say otherwise.
  2. One question on timing. A public test that leaves these ten out by id would show which refusals 0.18.0 lacks. The pull request with the vectors therefore waits on your word: would you like it now without the ten, or after your fix with the full set?
  3. An offer. For each item, a failing test or a fix in your private fork, whichever you prefer, and a re-test of any candidate build against both files.

Source: NVD

Related JavaScript vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-43rg-r2xr-q686MEDIUM5.9
  • JavaScript logoJavaScript
  • parse-server
NoYesNov 14, 2026
GHSA-pxwg-mv9c-h422LOWN/A
  • JavaScript logoJavaScript
  • @openeudi/openid4vp
NoYesNov 14, 2026
GHSA-qm7q-q8h9-jg8qMEDIUMN/A
  • JavaScript logoJavaScript
  • dompurify
NoYesNov 14, 2026
GHSA-662g-9vq2-39jxMEDIUMN/A
  • JavaScript logoJavaScript
  • qhhoj-backend
NoYesNov 14, 2026
GHSA-mh3m-3rhj-pqffLOWN/A
  • JavaScript logoJavaScript
  • qhhoj-backend
NoYesNov 14, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management