validation.js's top-level validate() function, when conf.bLight is true, returns success for any joint without a ball field before ever reaching validateAuthors() / Definition.validateAuthentifiers() — i.e. before any cryptographic signature is checked at all:
// validation.js:340-348
if (conf.bLight){
if (!isPositiveInteger(objUnit.timestamp) && !objJoint.unsigned)
return callbacks.ifJointError("bad timestamp");
if (objJoint.ball)
return callbacks.ifJointError(lightStableErrorMessage);
return objJoint.unsigned
? callbacks.ifOkUnsigned(true)
: callbacks.ifOk({sequence: 'good', arrDoubleSpendInputs: [], arrAdditionalQueries: []}, function(){});
}
validateLight() (validation.js:97-116) is a thin synchronous wrapper around this same validate(), and is exactly what light.js's processHistory calls on every joint in a hub's light/get_history response (light.js:245). Two checks that would have caught this were deliberately commented out (light.js:241-244, 251-252, commit 4975d23) with the justification "we receive unconfirmed units too" — a legitimate reason to accept genuinely unconfirmed/pending units, but that only justifies skipping the stability/proofchain check, not skipping signature verification entirely, which appears to be an unintended side effect rather than a considered decision.
Impact: a malicious or compromised light-vendor hub can inject a fully fabricated joint (arbitrary author address, garbage/empty authentifiers, self-consistent attacker-computed hash) into a get_history response, with a payment input referencing a victim's real, currently-unspent output. writer.saveJoint accepts it (since validateLight returned success), and light.js's fixIsSpentFlag (light.js:448-469) then flips the victim's real output to is_spent=1 in the local wallet database, based on nothing but this unverified, forged inputs row — with zero cryptographic basis. The victim's wallet will refuse to spend that real, intact output for as long as they stay connected to the malicious hub (the light-vendor URL is sticky and doesn't auto-fallback, and the 24h self-correction path queries the same hub that lied in the first place).
Note on real-world impact: the underlying value is never destroyed or moved to the attacker — it's a false-spent flag local to one victim's light-client database, recoverable by switching light vendor or re-importing the same seed elsewhere. This is why it doesn't meet a fund-loss bar, but it's still a real breach of the light-client protocol's core trust model (a light client is supposed to be safe against a lying hub for exactly this kind of thing).
Suggested fix: for unstable (non-ball) joints in light mode, still require and verify validateAuthors()/signature checks against the unit's declared author definitions, rather than skipping straight to ifOk.
validation.js's top-levelvalidate()function, whenconf.bLightis true, returns success for any joint without aballfield before ever reachingvalidateAuthors()/Definition.validateAuthentifiers()— i.e. before any cryptographic signature is checked at all:validateLight()(validation.js:97-116) is a thin synchronous wrapper around this samevalidate(), and is exactly whatlight.js'sprocessHistorycalls on every joint in a hub'slight/get_historyresponse (light.js:245). Two checks that would have caught this were deliberately commented out (light.js:241-244, 251-252, commit4975d23) with the justification "we receive unconfirmed units too" — a legitimate reason to accept genuinely unconfirmed/pending units, but that only justifies skipping the stability/proofchain check, not skipping signature verification entirely, which appears to be an unintended side effect rather than a considered decision.Impact: a malicious or compromised light-vendor hub can inject a fully fabricated joint (arbitrary author address, garbage/empty authentifiers, self-consistent attacker-computed hash) into a
get_historyresponse, with apaymentinput referencing a victim's real, currently-unspent output.writer.saveJointaccepts it (sincevalidateLightreturned success), andlight.js'sfixIsSpentFlag(light.js:448-469) then flips the victim's real output tois_spent=1in the local wallet database, based on nothing but this unverified, forgedinputsrow — with zero cryptographic basis. The victim's wallet will refuse to spend that real, intact output for as long as they stay connected to the malicious hub (the light-vendor URL is sticky and doesn't auto-fallback, and the 24h self-correction path queries the same hub that lied in the first place).Note on real-world impact: the underlying value is never destroyed or moved to the attacker — it's a false-spent flag local to one victim's light-client database, recoverable by switching light vendor or re-importing the same seed elsewhere. This is why it doesn't meet a fund-loss bar, but it's still a real breach of the light-client protocol's core trust model (a light client is supposed to be safe against a lying hub for exactly this kind of thing).
Suggested fix: for unstable (non-
ball) joints in light mode, still require and verifyvalidateAuthors()/signature checks against the unit's declared author definitions, rather than skipping straight toifOk.