From 9009b0d84436588b9f49bd38cfe65aa200638aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zochniak?= Date: Tue, 1 Sep 2026 15:09:04 +0200 Subject: [PATCH 1/6] Fix hex decoding of trailing junk Buffer.from(s, 'hex') silently drops invalid suffix bytes, so is_hex, unhex, and schema binary convert accepted malformed hex. --- lib/parse3.js | 27 +++++++++++++++++----- lib/schema3.js | 2 +- src/parse3.iced | 12 +++++++--- src/schema3.iced | 2 +- test/files/parse3.iced | 51 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 test/files/parse3.iced diff --git a/lib/parse3.js b/lib/parse3.js index b58fe9f9..fb8be712 100644 --- a/lib/parse3.js +++ b/lib/parse3.js @@ -1,6 +1,6 @@ -// Generated by IcedCoffeeScript 108.0.12 +// Generated by IcedCoffeeScript 108.0.11 (function() { - var bufeq_fast, constants, is_dict, is_hex, is_int, pack, unpack, unpack_strict, + var bufeq_fast, constants, decode_hex, is_dict, is_hex, is_int, pack, unpack, unpack_strict, __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; unpack = require('purepack').unpack; @@ -45,13 +45,27 @@ return typeof s === 'number' && (n !== Infinity) && (n === s) && n >= 0; }; + exports.decode_hex = decode_hex = function(s) { + var buf; + if (typeof s !== 'string') { + return null; + } + buf = Buffer.from(s, 'hex'); + if (buf.toString('hex') !== s.toLowerCase()) { + return null; + } + return buf; + }; + exports.is_hex = is_hex = function(h, l) { if (h == null) { return false; } if (typeof h === 'string') { - h = Buffer.from(h, 'hex'); - } else if (!Buffer.isBuffer) { + if ((h = decode_hex(h)) == null) { + return false; + } + } else if (!Buffer.isBuffer(h)) { return false; } return h.length === l; @@ -167,12 +181,13 @@ }; exports.unhex = function(b) { + var h; if (b == null) { return null; } else if (Buffer.isBuffer(b)) { return b; - } else if (typeof b === 'string') { - return Buffer.from(b, 'hex'); + } else if (typeof b === 'string' && ((h = decode_hex(b)) != null)) { + return h; } else { throw new Error("bad binary or hex string"); } diff --git a/lib/schema3.js b/lib/schema3.js index 6d5ecd89..d0e30fe1 100644 --- a/lib/schema3.js +++ b/lib/schema3.js @@ -282,7 +282,7 @@ var bot, obj, path; path = _arg.path, obj = _arg.obj; if (this._convert && typeof obj === 'string') { - obj = Buffer.from(obj, 'hex'); + obj = parse.decode_hex(obj); } if (!(Buffer.isBuffer(obj) && obj.length === this._len)) { return [mkerr(path, "value needs to be buffer of length " + this._len), null]; diff --git a/src/parse3.iced b/src/parse3.iced index 0a9c3f38..c9a886ef 100644 --- a/src/parse3.iced +++ b/src/parse3.iced @@ -15,10 +15,16 @@ exports.is_kid = (h) -> is_hex(h,35) exports.is_int = is_int = (s) -> n = Math.floor Number s return typeof(s) is 'number' and (n isnt Infinity) and (n is s) and n >= 0 +exports.decode_hex = decode_hex = (s) -> + return null unless typeof(s) is 'string' + buf = Buffer.from s, 'hex' + return null unless buf.toString('hex') is s.toLowerCase() + buf exports.is_hex = is_hex = (h, l) -> return false unless h? - if typeof(h) is 'string' then h = Buffer.from(h, 'hex') - else if not Buffer.isBuffer then return false + if typeof(h) is 'string' + return false unless (h = decode_hex(h))? + else if not Buffer.isBuffer(h) then return false return (h.length is l) exports.is_seqno = (s) -> return false unless s? @@ -75,6 +81,6 @@ exports.is_chain_type = (x) -> exports.unhex = (b) -> if not b? then null else if Buffer.isBuffer(b) then b - else if typeof(b) is 'string' then Buffer.from(b, 'hex') + else if typeof(b) is 'string' and (h = decode_hex(b))? then h else throw new Error "bad binary or hex string" diff --git a/src/schema3.iced b/src/schema3.iced index 62208edd..42d2ad9c 100644 --- a/src/schema3.iced +++ b/src/schema3.iced @@ -125,7 +125,7 @@ class Binary extends Node _convert_and_check : ({path, obj}) -> if @_convert and typeof(obj) is 'string' - obj = Buffer.from(obj, 'hex') + obj = parse.decode_hex(obj) unless Buffer.isBuffer(obj) and obj.length is @_len return [ (mkerr path, "value needs to be buffer of length #{@_len}"), null ] if @_bottom_bytes? diff --git a/test/files/parse3.iced b/test/files/parse3.iced new file mode 100644 index 00000000..6c8da775 --- /dev/null +++ b/test/files/parse3.iced @@ -0,0 +1,51 @@ +parse3 = require '../../lib/parse3' +schema = require '../../lib/schema3' + +exports.hex_rejects_trailing_junk = (T, cb) -> + uid = "00".repeat(15) + "19" + hash = "00".repeat(32) + + uid_upper = "AA".repeat(15) + "19" + + T.assert parse3.is_hex(uid, 16), "valid uid hex is accepted" + T.assert parse3.is_hex(uid_upper, 16), "uppercase uid hex is accepted" + T.assert not parse3.is_hex(uid + "zz", 16), "uid hex plus junk is rejected" + T.assert not parse3.is_hex(uid + "a", 16), "odd-length uid hex is rejected" + T.assert not parse3.is_hex(hash + "nothex", 32), "hash hex plus junk is rejected" + + T.assert not parse3.is_uid(hash), "length has to match" + + err = schema.uid().convert().check uid + T.assert not err?, "valid uid converts" + + err = schema.uid().convert().check uid_upper + T.assert not err?, "uppercase uid converts" + + err = schema.uid().convert().check hash + T.assert err?, "length has to match" + + err = schema.uid().convert().check uid + "zz" + T.assert err?, "uid with trailing junk is rejected" + + err = schema.uid().convert().check uid + "a" + T.assert err?, "odd-length uid is rejected" + + err = schema.hash().convert().check hash + "zz" + T.assert err?, "hash with trailing junk is rejected" + + out = parse3.unhex uid + T.assert Buffer.isBuffer(out) and out.length is 16, "unhex accepts valid uid" + + out = parse3.unhex uid_upper + T.assert Buffer.isBuffer(out) and out.length is 16, "unhex accepts uppercase uid" + + for bad in [uid + "zz", uid + "a"] + err = null + try + parse3.unhex bad + catch e + err = e + T.assert err?, "unhex rejects #{bad}" + T.assert err.toString().indexOf("bad binary or hex string") >= 0, "unhex error message" + + cb null From 23fddf39efd0c60e370681fe1f758687689c4ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zochniak?= Date: Tue, 1 Sep 2026 15:09:27 +0200 Subject: [PATCH 2/6] Fix Or combinator in schema3 check() returns an error or null, so the loop was treating failures as matches and accepting no valid term. --- lib/schema3.js | 2 +- src/schema3.iced | 2 +- test/files/schema3.iced | 25 +++++++++++++++++++++++++ test/files/wot.iced | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 test/files/schema3.iced diff --git a/lib/schema3.js b/lib/schema3.js index d0e30fe1..ef687278 100644 --- a/lib/schema3.js +++ b/lib/schema3.js @@ -573,7 +573,7 @@ _ref = this._terms; for (_i = 0, _len = _ref.length; _i < _len; _i++) { t = _ref[_i]; - if (!(t.check(obj))) { + if (!(!t.check(obj))) { continue; } ok = true; diff --git a/src/schema3.iced b/src/schema3.iced index 42d2ad9c..1006f988 100644 --- a/src/schema3.iced +++ b/src/schema3.iced @@ -218,7 +218,7 @@ class Or extends Node @_terms = terms _check : ({path, obj}) -> ok = false - for t in @_terms when t.check(obj) + for t in @_terms when not t.check(obj) ok = true break if not ok then return mkerr path, "no structure worked" diff --git a/test/files/schema3.iced b/test/files/schema3.iced new file mode 100644 index 00000000..bdc8deeb --- /dev/null +++ b/test/files/schema3.iced @@ -0,0 +1,25 @@ +schema = require '../../lib/schema3' + +exports.or_combinator = (T, cb) -> + schm = schema.or([ + schema.dict({ + name : schema.string() + username : schema.string() + }) + schema.dict({ + protocol : schema.string() + hostname : schema.string() + }) + ]) + + err = schm.check { name : "reddit", username : "alice" } + T.assert not err?, "object matching first term is accepted" + + err = schm.check { protocol : "https:", hostname : "example.com" } + T.assert not err?, "object matching second term is accepted" + + err = schm.check { invalid : 1, obj : 2 } + T.assert err?, "object matching no term is rejected" + T.equal err?.message, "At : no structure worked" + + cb null diff --git a/test/files/wot.iced b/test/files/wot.iced index 200c0d38..458fda3a 100644 --- a/test/files/wot.iced +++ b/test/files/wot.iced @@ -97,3 +97,38 @@ exports.wot_vouch_bad = (T,cb) -> T.assert (err.message.indexOf(".confidence.other") >= 0), "found right error message" cb null + +exports.wot_vouch_bad_proof = (T,cb) -> + esc = make_esc cb + await new_km_and_sig_arg {}, esc defer me + await new_km_and_sig_arg {}, esc defer them + proof1 = { proof_type : 4, name : "reddit", username : "betaveros" } + bad_proof = { invalid : 1, obj : 2 } + me.wot = + vouch : + user : + username : them.user.local.username + uid : them.user.local.uid + eldest: + kid : them.sig_eng.km.key.ekid().toString('hex') + seqno : 1 + seq_tail : + seqno : 20 + sig_id : new_sig_id() + payload_hash : new_payload_hash() + confidence : + username_verified_via : "audio" + other : "lorem ipsum" + proofs : [ proof1, bad_proof ] + vouch_text : "darn rootin tootin" + + obj = new wot.Vouch me + await obj.generate_v2 esc(defer(out)), {dohash:true} + + verifier = alloc out.inner.obj.body.type, me + varg = { armored : out.armored, skip_ids : true, make_ids : true, inner : out.inner.str, expansions : out.expansions, require_packet_hash :true} + await verifier.verify_v2 varg, defer err + T.assert err?, "got an error back" + T.equal err?.message, "At .confidence.proofs.1: no structure worked" + + cb null \ No newline at end of file From 64f76671f4e6bc472d8b12a418fc72140600296a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zochniak?= Date: Tue, 1 Sep 2026 15:09:48 +0200 Subject: [PATCH 3/6] Return errors for null schema values _check_value built a null error and dropped it, so required fields accepted null and undefined. --- lib/schema3.js | 2 +- src/schema3.iced | 2 +- test/files/schema3.iced | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/schema3.js b/lib/schema3.js index ef687278..09aa1bb9 100644 --- a/lib/schema3.js +++ b/lib/schema3.js @@ -84,7 +84,7 @@ return null; } if (obj == null) { - mkerr(path, "value cannot be null"); + return mkerr(path, "value cannot be null"); } return checker._check({ path: path, diff --git a/src/schema3.iced b/src/schema3.iced index 1006f988..181edc5c 100644 --- a/src/schema3.iced +++ b/src/schema3.iced @@ -33,7 +33,7 @@ class Node _check_value : ({checker, path, obj}) -> if not obj? and checker.is_optional() then return null - if not obj? then mkerr path, "value cannot be null" + if not obj? then return mkerr path, "value cannot be null" return checker._check { path, obj } class Dict extends Node diff --git a/test/files/schema3.iced b/test/files/schema3.iced index bdc8deeb..08c324c3 100644 --- a/test/files/schema3.iced +++ b/test/files/schema3.iced @@ -23,3 +23,31 @@ exports.or_combinator = (T, cb) -> T.equal err?.message, "At : no structure worked" cb null + +exports.required_null_rejected = (T, cb) -> + for val in [null, undefined] + err = schema.array(schema.obj()).check [val] + T.assert err?, "array of obj() rejects null" + T.equal err?.message, "At .0: value cannot be null" + + err = schema.array(schema.string()).check [val] + T.assert err?, "array of obj() rejects null" + T.equal err?.message, "At .0: value cannot be null" + + err = schema.struct([schema.obj()]).check [val] + T.assert err?, "struct of obj() rejects null" + T.equal err?.message, "At .0: value cannot be null" + + err = schema.struct([schema.string()]).check [val] + T.assert err?, "struct of obj() rejects null" + T.equal err?.message, "At .0: value cannot be null" + + err = schema.dict({ x : schema.obj() }).check { x : val } + T.assert err?, "dict required obj() null is rejected" + T.equal err?.message, "At .x: value cannot be null" + + err = schema.dict({ x : schema.string() }).check { x : val } + T.assert err?, "dict required string null is rejected" + T.equal err?.message, "At .x: value cannot be null" + + cb null From a01ec4d932d7d14d066b5b05140e25dff629c59a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zochniak?= Date: Thu, 3 Sep 2026 18:33:10 +0200 Subject: [PATCH 4/6] Reject __proto__ keys and use own-property lookups in schema3 Object.prototype names were treated as present schema keys, and __proto__ can set [[Prototype]] instead of a data field. --- lib/schema3.js | 76 +++++++++------ lib/util.js | 16 +++- src/schema3.iced | 21 ++++- src/util.iced | 11 +++ test/files/schema3.iced | 202 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 293 insertions(+), 33 deletions(-) diff --git a/lib/schema3.js b/lib/schema3.js index 09aa1bb9..4ffca832 100644 --- a/lib/schema3.js +++ b/lib/schema3.js @@ -1,16 +1,22 @@ -// Generated by IcedCoffeeScript 108.0.12 +// Generated by IcedCoffeeScript 108.0.11 (function() { - var Array, Binary, Bool, ChainType, Dict, Int, KID, LinkType, Node, Object, Or, Path, PtkType, Seqno, String, StringEnum, Struct, Time, Value, mkerr, parse, + var Array, Binary, Bool, ChainType, Dict, Int, KID, LinkType, Node, Object, Or, Path, PtkType, Seqno, String, StringEnum, Struct, Time, Value, get_own, has_own, is_reserved_key_name, mkerr, parse, _ref, __hasProp = {}.hasOwnProperty, __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; parse = require('./parse3'); + _ref = require('./util'), has_own = _ref.has_own, get_own = _ref.get_own; + mkerr = function(path, err) { return new Error("At " + (path.toString()) + ": " + err); }; + is_reserved_key_name = function(k) { + return k === '__proto__'; + }; + Path = (function() { function Path(v) { this._v = v || []; @@ -100,15 +106,20 @@ __extends(Dict, _super); function Dict(_arg) { - var keys; + var k, keys; keys = _arg.keys; + for (k in keys) { + if (is_reserved_key_name(k)) { + throw new Error("schema key name is not allowed: " + k); + } + } this._keys = keys; this._allow_extra_keys = false; Dict.__super__.constructor.apply(this, arguments); } Dict.prototype._check = function(_arg) { - var checker, err, k, new_path, obj, path, v, _ref; + var checker, err, k, new_path, obj, path, v, _ref1; path = _arg.path, obj = _arg.obj; if (!parse.is_dict(obj)) { return mkerr(path, "need a dictionary"); @@ -116,7 +127,10 @@ for (k in obj) { v = obj[k]; new_path = path.extend(k); - if ((checker = this._keys[k]) == null) { + if (is_reserved_key_name(k)) { + return mkerr(new_path, "key name is not allowed"); + } + if ((checker = get_own(this._keys, k)) == null) { if (this._allow_extra_keys) { continue; } @@ -130,11 +144,11 @@ return err; } } - _ref = this._keys; - for (k in _ref) { - v = _ref[k]; + _ref1 = this._keys; + for (k in _ref1) { + v = _ref1[k]; new_path = path.extend(k); - if ((obj[k] == null) && !v.is_optional()) { + if (!has_own(obj, k) && !v.is_optional()) { return mkerr(new_path, "key is missing but is mandatory"); } } @@ -142,12 +156,12 @@ }; Dict.prototype.debug_localize = function(obj) { - var k, ret, v, _ref; + var k, ret, v, _ref1; ret = {}; - _ref = this._keys; - for (k in _ref) { - v = _ref[k]; - if (obj[k] != null) { + _ref1 = this._keys; + for (k in _ref1) { + v = _ref1[k]; + if (has_own(obj, k)) { ret[v._name || k] = v.debug_localize(obj[k]); } } @@ -155,6 +169,9 @@ }; Dict.prototype.set_key = function(k, v) { + if (is_reserved_key_name(k)) { + throw new Error("schema key name is not allowed: " + k); + } return this._keys[k] = v; }; @@ -297,12 +314,12 @@ }; Binary.prototype._check = function(_arg) { - var err, obj, path, _, _ref; + var err, obj, path, _, _ref1; path = _arg.path, obj = _arg.obj; - _ref = this._convert_and_check({ + _ref1 = this._convert_and_check({ path: path, obj: obj - }), err = _ref[0], _ = _ref[1]; + }), err = _ref1[0], _ = _ref1[1]; return err; }; @@ -321,17 +338,17 @@ } KID.prototype._check = function(_arg) { - var err, obj, path, typ, _ref, _ref1; + var err, obj, path, typ, _ref1, _ref2; path = _arg.path, obj = _arg.obj; - _ref = this._convert_and_check({ + _ref1 = this._convert_and_check({ path: path, obj: obj - }), err = _ref[0], obj = _ref[1]; + }), err = _ref1[0], obj = _ref1[1]; if (err != null) { return err; } typ = this._encryption ? [0x21] : [0x20, 0x01, 0x11, 0x13, 0x16]; - if ((obj[0] !== 0x01) || (_ref1 = obj[1], __indexOf.call(typ, _ref1) < 0) || (obj.slice(-1)[0] !== 0x0a)) { + if ((obj[0] !== 0x01) || (_ref2 = obj[1], __indexOf.call(typ, _ref2) < 0) || (obj.slice(-1)[0] !== 0x0a)) { return mkerr(path, "value must be a KID" + (this._encryption ? ' (for encryption)' : '')); } return null; @@ -431,12 +448,12 @@ } String.prototype._check = function(_arg) { - var max_length, obj, path, _ref; + var max_length, obj, path, _ref1; path = _arg.path, obj = _arg.obj; if (typeof obj !== 'string' || obj.length === 0) { return mkerr(path, "value must be a string"); } - if ((max_length = (_ref = this._args) != null ? _ref.max_length : void 0) != null) { + if ((max_length = (_ref1 = this._args) != null ? _ref1.max_length : void 0) != null) { if (obj.length > max_length) { return mkerr(path, "value length needs to be < " + max_length); } @@ -457,6 +474,9 @@ this._values = {}; for (_i = 0, _len = values.length; _i < _len; _i++) { v = values[_i]; + if (is_reserved_key_name(v)) { + throw new Error("enum value is not allowed: " + v); + } this._values[v] = true; } } @@ -467,7 +487,7 @@ if (typeof obj !== 'string') { return mkerr(path, "value must be a string"); } - if (!this._values[obj]) { + if (!has_own(this._values, obj)) { return mkerr(path, "unknown enum value (" + obj + ")"); } return null; @@ -567,12 +587,12 @@ } Or.prototype._check = function(_arg) { - var obj, ok, path, t, _i, _len, _ref; + var obj, ok, path, t, _i, _len, _ref1; path = _arg.path, obj = _arg.obj; ok = false; - _ref = this._terms; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - t = _ref[_i]; + _ref1 = this._terms; + for (_i = 0, _len = _ref1.length; _i < _len; _i++) { + t = _ref1[_i]; if (!(!t.check(obj))) { continue; } diff --git a/lib/util.js b/lib/util.js index 74129a08..8fa82550 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,6 +1,6 @@ // Generated by IcedCoffeeScript 108.0.11 (function() { - var Lock, bufeq_secure, constants, crypto, json_secure_compare, json_stringify_sorted, pack, v2_sig_type_from_sig_type, _ref; + var Lock, bufeq_secure, constants, crypto, get_own, has_own, json_secure_compare, json_stringify_sorted, pack, v2_sig_type_from_sig_type, _has_own, _ref; constants = require('./constants').constants; @@ -10,6 +10,20 @@ pack = require('purepack').pack; + _has_own = {}.constructor.prototype.hasOwnProperty; + + exports.has_own = has_own = function(o, k) { + return _has_own.call(o, k); + }; + + exports.get_own = get_own = function(o, k) { + if (_has_own.call(o, k)) { + return o[k]; + } else { + return void 0; + } + }; + exports.json_secure_compare = json_secure_compare = function(a, b) { var err, o1, o2, x, _ref1; _ref1 = (function() { diff --git a/src/schema3.iced b/src/schema3.iced index 181edc5c..53fc015b 100644 --- a/src/schema3.iced +++ b/src/schema3.iced @@ -1,8 +1,16 @@ parse = require './parse3' +{has_own,get_own} = require './util' mkerr = (path, err) -> new Error "At #{path.toString()}: #{err}" +# __proto__ is not a data key. `{ __proto__: x }` and `obj["__proto__"] = x` +# set [[Prototype]] instead of an own property. JSON.parse may store it as an +# own key; msgpack unpack may throw. Ban it as a schema and payload key. +# constructor, toString, hasOwnProperty, etc. are allowed; has_own treats them +# as own keys. +is_reserved_key_name = (k) -> k is '__proto__' + class Path constructor : (v) -> @_v = v or [] @@ -38,6 +46,8 @@ class Node class Dict extends Node constructor : ({keys}) -> + for k of keys + if is_reserved_key_name k then throw new Error "schema key name is not allowed: #{k}" @_keys = keys # do not fail if there are extra keys unknown to schema @_allow_extra_keys = false @@ -48,22 +58,24 @@ class Dict extends Node return mkerr path, "need a dictionary" for k,v of obj new_path = path.extend(k) - if not (checker = @_keys[k])? + if is_reserved_key_name k then return mkerr new_path, "key name is not allowed" + if not (checker = get_own(@_keys, k))? if @_allow_extra_keys then continue return mkerr new_path, "key is not supported" if (err = @_check_value { checker, path : new_path, obj : v }) then return err for k,v of @_keys new_path = path.extend(k) - if not obj[k]? and not v.is_optional() then return mkerr new_path, "key is missing but is mandatory" + if not has_own(obj, k) and not v.is_optional() then return mkerr new_path, "key is missing but is mandatory" return null debug_localize : (obj) -> ret = {} - for k,v of @_keys when obj[k]? + for k,v of @_keys when has_own obj, k ret[v._name or k] = v.debug_localize obj[k] ret set_key : (k,v) -> + if is_reserved_key_name k then throw new Error "schema key name is not allowed: #{k}" @_keys[k] = v allow_extra_keys : () -> @@ -186,10 +198,11 @@ class StringEnum extends Node constructor : ({values}) -> @_values = {} for v in values + if is_reserved_key_name v then throw new Error "enum value is not allowed: #{v}" @_values[v] = true _check : ({path, obj}) -> if typeof(obj) isnt 'string' then return mkerr path, "value must be a string" - if not @_values[obj] then return mkerr path, "unknown enum value (#{obj})" + if not has_own(@_values, obj) then return mkerr path, "unknown enum value (#{obj})" return null class Value extends Node diff --git a/src/util.iced b/src/util.iced index 31990cc3..c4cc6159 100644 --- a/src/util.iced +++ b/src/util.iced @@ -5,6 +5,17 @@ crypto = require 'crypto' #---------- +# Native hasOwnProperty, captured so schema/input keys named +# "hasOwnProperty" cannot shadow the method. Use instead of obj[k] or +# obj.hasOwnProperty(k) — those see Object.prototype (constructor, toString, …). +_has_own = {}.constructor.prototype.hasOwnProperty +exports.has_own = has_own = (o, k) -> _has_own.call o, k +exports.get_own = get_own = (o, k) -> + if _has_own.call(o, k) then o[k] + else undefined + +#---------- + exports.json_secure_compare = json_secure_compare = (a,b) -> [o1,o2] = (json_stringify_sorted(x) for x in [a,b]) err = if bufeq_secure((Buffer.from o1, 'utf8'), (Buffer.from o2, 'utf8')) then null diff --git a/test/files/schema3.iced b/test/files/schema3.iced index 08c324c3..02c3dab4 100644 --- a/test/files/schema3.iced +++ b/test/files/schema3.iced @@ -1,4 +1,55 @@ schema = require '../../lib/schema3' +{pack,unpack} = require 'purepack' + +# These collide with Object.prototype and with lookups like obj[k] / +# obj.hasOwnProperty(k). +_object_proto_names = [ + "__proto__" + "constructor" + "hasOwnProperty" + "isPrototypeOf" + "propertyIsEnumerable" + "toLocaleString" + "toString" + "valueOf" +] +# "Weird key names" - Object prototype keys but handled by schema3 library. +# With the exception of __proto__ - this one is banned because it requires +# extra care to get right and has more footguns. See src/schema3.iced +# is_reserved_key_name +_weird_key_names = (n for n in _object_proto_names when n isnt "__proto__") + +exports.prototype_pollution_json = (T, cb) -> + # parsing "__proto__" property should not change resulting object's + # prototype. + obj = JSON.parse('{"__proto__":1}') + T.assert Object.getPrototypeOf(obj) is Object.prototype, "is Object.prototype" + T.assert Object::hasOwnProperty.call(obj, '__proto__'), "hasOwnProperty __proto__" + + cb null + +exports.prototype_pollution_purepack = (T, cb) -> + # purepack does not allow __proto__ keys, although accidentially. + + # Create a purepack with aaaaaaaaa property + aaa = Buffer.from (pack { aaaaaaaaa : 1 }) + + # Sanity check, 'aaaaaaaaa' is just a normal key in an unpacked object. + obj = unpack aaa + T.assert Object.getPrototypeOf(obj) is Object.prototype, "is Object.prototype" + T.assert Object::hasOwnProperty.call(obj, 'aaaaaaaaa'), "hasOwnProperty aaaaaaaaa" + + # Str-replace aaaaaaaaa into __proto__ + bin = Buffer.from(aaa.toString('binary').replace('aaaaaaaaa', '__proto__'), 'binary') + err = null + try + unpack(bin) + catch e + err = e + T.assert err? + T.equal err?.message, "duplicate key '__proto__'" + + cb null exports.or_combinator = (T, cb) -> schm = schema.or([ @@ -51,3 +102,154 @@ exports.required_null_rejected = (T, cb) -> T.equal err?.message, "At .x: value cannot be null" cb null + +exports.dict_missing_mandatory_key = (T, cb) -> + schm = schema.dict({ + n : schema.string().name("name") + nick : schema.string().optional() + }) + + err = schm.check { n : "alice" } + T.assert not err?, "optional key may be omitted" + + err = schm.check { nick : "a" } + T.assert err?, "missing mandatory key is rejected" + T.equal err?.message, "At .n: key is missing but is mandatory" + + err = schm.check {} + T.assert err?, "empty dict missing mandatory key is rejected" + T.equal err?.message, "At .n: key is missing but is mandatory" + + localized = schm.debug_localize { n : "alice" } + T.equal localized.name, "alice", "debug_localize uses schema names" + T.assert not localized.n?, "short key is replaced" + + cb null + +exports.string_enum_rejects_unknown_weird_names = (T, cb) -> + schm = schema.string_enum ["in_person", "proofs", "video"] + err = schm.check "video" + T.assert not err?, "listed enum value is accepted" + + err = schm.check "nope" + T.assert err?, "unknown enum value is rejected" + + # Make sure that Object prototype names are not bugging out with string enum check. + for name in _object_proto_names + err = schm.check name + T.assert err?, "#{name} is not an enum value just because it is a prototype name" + T.assert (err?.message.indexOf("unknown enum value") >= 0), "#{name} error message" + + cb null + +exports.string_enum_allows_weird_names = (T, cb) -> + for name in _weird_key_names + # Allow a string enum with weird name. + schm = schema.string_enum [name] + # And it can also be checked properly + err = schm.check name + T.assert not err?, "#{name} can be an enum value" + err = schm.check "nope" + T.assert err?, "other values still rejected next to #{name}" + + # Do not allow banned __proto__ name. + threw = null + try + schema.string_enum ["__proto__"] + catch e + threw = e + T.assert threw?, "__proto__ is not a valid enum value" + T.equal threw?.message, "enum value is not allowed: __proto__" + + cb null + +exports.dict_rejects_unknown_weird_keys = (T, cb) -> + schm = schema.dict { name : schema.string() } + err = schm.check { name : "ok" } + T.assert not err?, "valid dict is accepted" + + err = schm.check { name : "ok", extra : 1 } + T.assert err?, "unknown key is rejected" + T.equal err?.message, "At .extra: key is not supported" + + # Allow weird key names to be checked against the schema, they are just not + # defined as dict fields and payloads with these fields are correctly + # rejected. They are not rejected / banned, and are treated as any other name + # (also see next test). + for name in _weird_key_names + obj = JSON.parse "{\"name\":\"ok\",\"#{name}\":1}" + err = null + threw = null + try + err = schm.check obj + catch e + threw = e + T.assert not threw?, "#{name} should not throw" + T.assert err?, "#{name} extra key is not a schema field" + T.equal err?.message, "At .#{name}: key is not supported" + + obj = JSON.parse '{"name":"ok","__proto__":1}' + err = schm.check obj + T.assert err?, "__proto__ extra key is rejected" + T.equal err?.message, "At .__proto__: key name is not allowed" + + schm = schema.dict({ name : schema.string() }).allow_extra_keys() + obj = JSON.parse '{"name":"ok","constructor":1}' + err = schm.check obj + T.assert not err?, "constructor extra key is allowed when extra keys are allowed" + + obj = JSON.parse '{"name":"ok","__proto__":1}' + err = schm.check obj + T.assert err?, "__proto__ is rejected even with allow_extra_keys" + T.equal err?.message, "At .__proto__: key name is not allowed" + + cb null + +exports.dict_weird_schema_keys = (T, cb) -> + # Weird keys can be used in the schema as well, and objects with these + # keys can be checked against the schemas. + for name in _weird_key_names + keys = {} + keys[name] = schema.string() + schm = schema.dict keys + + own = JSON.parse "{\"#{name}\":\"ok\"}" + err = schm.check own + T.assert not err?, "own #{name} key is accepted" + + err = schm.check {} + T.assert err?, "inherited #{name} does not satisfy a required schema key" + T.equal err?.message, "At .#{name}: key is missing but is mandatory" + + localized = schm.debug_localize {} + T.assert not Object::hasOwnProperty.call(localized, name), "debug_localize ignores inherited #{name}" + localized = schm.debug_localize own + T.equal localized[name], "ok", "own #{name} key is localized" + + schm = schema.dict { name : schema.string() } + schm.set_key name, schema.string() + obj = JSON.parse "{\"name\":\"ok\",\"#{name}\":\"ok\"}" + err = schm.check obj + T.assert not err?, "set_key can install #{name}" + + # With the exception of __proto__ which is rejected in the schema construction stage. + keys = {} + Object.defineProperty keys, "__proto__", { value : schema.string(), enumerable : true } + threw = null + try + schema.dict keys + catch e + threw = e + T.assert threw?, "__proto__ is not a valid schema key" + T.equal threw?.message, "schema key name is not allowed: __proto__" + + schm = schema.dict { name : schema.string() } + threw = null + try + schm.set_key "__proto__", schema.string() + catch e + threw = e + T.assert threw?, "set_key rejects __proto__" + T.equal threw?.message, "schema key name is not allowed: __proto__" + + cb null From 12bee87a61ea7a1d62394a0a0b980cc5b742b9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zochniak?= Date: Thu, 3 Sep 2026 18:33:36 +0200 Subject: [PATCH 5/6] Reject Object.prototype names as proof types in alloc lookup_tab[type] and extra_lookup_tab[type] followed the prototype chain, so names like toString resolved to functions instead of missing classes. --- lib/alloc.js | 8 +++++--- src/alloc.iced | 3 ++- test/files/alloc.iced | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 test/files/alloc.iced diff --git a/lib/alloc.js b/lib/alloc.js index bfbc48ec..4b68836d 100644 --- a/lib/alloc.js +++ b/lib/alloc.js @@ -1,6 +1,6 @@ -// Generated by IcedCoffeeScript 108.0.12 +// Generated by IcedCoffeeScript 108.0.11 (function() { - var Announcement, Auth, Cryptocurrency, Device, Eldest, PGPUpdate, PerUserKey, Revoke, Sibkey, Stellar, Subkey, Track, Untrack, UpdatePassphraseHash, UpdateSettings, alloc, base, get_klass, lookup_tab, team, team_hidden, web_service, wot, _ref; + var Announcement, Auth, Cryptocurrency, Device, Eldest, PGPUpdate, PerUserKey, Revoke, Sibkey, Stellar, Subkey, Track, Untrack, UpdatePassphraseHash, UpdateSettings, alloc, base, get_klass, get_own, lookup_tab, team, team_hidden, web_service, wot, _ref; web_service = require('./web_service'); @@ -34,6 +34,8 @@ UpdateSettings = require('./update_settings').UpdateSettings; + get_own = require('./util').get_own; + team = require('./team'); team_hidden = require('./team_hidden'); @@ -90,7 +92,7 @@ get_klass = function(type, extra_lookup_tab) { var err, klass; err = klass = null; - if (!(((klass = extra_lookup_tab != null ? extra_lookup_tab[type] : void 0) != null) || ((klass = lookup_tab[type]) != null))) { + if (!(((extra_lookup_tab != null) && ((klass = get_own(extra_lookup_tab, type)) != null)) || ((klass = get_own(lookup_tab, type)) != null))) { err = new Error("Unknown proof class: " + type); } return [err, klass]; diff --git a/src/alloc.iced b/src/alloc.iced index 641155ab..bee51fba 100644 --- a/src/alloc.iced +++ b/src/alloc.iced @@ -15,6 +15,7 @@ base = require './base' {PGPUpdate} = require './pgp_update' {UpdatePassphraseHash} = require './update_passphrase_hash' {UpdateSettings} = require './update_settings' +{get_own} = require './util' team = require './team' team_hidden = require './team_hidden' wot = require './wot' @@ -73,7 +74,7 @@ lookup_tab = { get_klass = (type, extra_lookup_tab) -> err = klass = null - unless (klass = extra_lookup_tab?[type])? or (klass = lookup_tab[type])? + unless (extra_lookup_tab? and (klass = get_own(extra_lookup_tab, type))?) or (klass = get_own(lookup_tab, type))? err = new Error "Unknown proof class: #{type}" [err, klass] diff --git a/test/files/alloc.iced b/test/files/alloc.iced new file mode 100644 index 00000000..b097c861 --- /dev/null +++ b/test/files/alloc.iced @@ -0,0 +1,40 @@ +{alloc,get_klass} = require '../../' + +exports.test_unknown_type = (T,cb) -> + for type in ["", "not_a_real_type", "toString", "constructor", "__proto__"] + [err, klass] = get_klass type + T.assert err?, "error" + T.equal err.message, "Unknown proof class: #{type}", "right message" + T.assert not klass?, "no klass" + T.assert not(alloc type, {}), "alloc returns null" + + cb null + +exports.test_extra_lookup_tab = (T,cb) -> + class Dummy + constructor : (args) -> @args = args + + # only used in keybase-proofs-test + extra_lookup_tab = { + "test.web_service_binding.rooter" : Dummy + } + + [err, klass] = get_klass "test.web_service_binding.rooter", extra_lookup_tab + T.assert not err?, "no error for extra type" + T.equal klass, Dummy, "extra type klass" + obj = alloc "test.web_service_binding.rooter", {foo : 1}, extra_lookup_tab + T.assert obj?, "alloc extra type" + T.equal obj.args.foo, 1, "args passed through" + + [err, klass] = get_klass "track", extra_lookup_tab + T.assert not err?, "builtin still found" + T.assert klass?, "builtin klass" + + for type in ["", "not_a_real_type", "toString", "constructor", "__proto__"] + [err, klass] = get_klass type, extra_lookup_tab + T.assert err?, "error" + T.equal err.message, "Unknown proof class: #{type}", "right message" + T.assert not klass?, "no klass" + T.assert not(alloc type, {}, extra_lookup_tab), "alloc returns null" + + cb null From 3430dd47e5bd64c4a5212a282a5071a3e0556819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Zochniak?= Date: Thu, 3 Sep 2026 18:33:46 +0200 Subject: [PATCH 6/6] Fix TwitterScraper.users_lookup by using a Map --- lib/scrapers/twitter.js | 26 +++++++++++++------------- src/scrapers/twitter.iced | 6 +++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/scrapers/twitter.js b/lib/scrapers/twitter.js index ae2db7ce..478e68a5 100644 --- a/lib/scrapers/twitter.js +++ b/lib/scrapers/twitter.js @@ -113,7 +113,7 @@ return (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper.hunt2" }); _this._get_body_api({ @@ -174,7 +174,7 @@ }; TwitterScraper.prototype.users_lookup = function(_arg, cb) { - var batch_size, cursor_wait, dict, done, err, i, identifier, ids, include_entities, input_list, j, json, key, query, r, rc, res, responses, screen_names, u, ___iced_passed_deferral, __iced_deferrals, __iced_k; + var batch_size, cursor_wait, done, err, i, identifier, ids, include_entities, input_list, j, json, key, map, query, r, rc, res, responses, screen_names, u, ___iced_passed_deferral, __iced_deferrals, __iced_k; __iced_k = __iced_k_noop; ___iced_passed_deferral = iced.findDeferral(arguments); ids = _arg.ids, screen_names = _arg.screen_names, cursor_wait = _arg.cursor_wait, include_entities = _arg.include_entities; @@ -222,7 +222,7 @@ (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper.users_lookup" }); _this._get_body_api({ @@ -265,7 +265,7 @@ (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper.users_lookup" }); setTimeout(__iced_deferrals.defer({ @@ -294,16 +294,16 @@ return function() { var _i, _j, _len, _len1; if (responses != null ? responses.length : void 0) { - dict = {}; + map = new Map; key = ids != null ? "id_str" : "screen_name"; for (_i = 0, _len = responses.length; _i < _len; _i++) { r = responses[_i]; - dict[r[key]] = r; + map.set(String(r[key]), r); } res = []; for (i = _j = 0, _len1 = input_list.length; _j < _len1; i = ++_j) { identifier = input_list[i]; - res[i] = dict[identifier] || null; + res[i] = map.get(String(identifier)) || null; } } return cb(err, res); @@ -351,7 +351,7 @@ (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper.get_follower_ids" }); _this._get_body_api({ @@ -397,7 +397,7 @@ (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper.get_follower_ids" }); setTimeout(__iced_deferrals.defer({ @@ -518,7 +518,7 @@ return (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper.check_status" }); _this._get_url_body({ @@ -605,7 +605,7 @@ return (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper._get_bearer_token" }); bt.get(__iced_deferrals.defer({ @@ -637,7 +637,7 @@ return (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper._get_body_api" }); _this._get_bearer_token(__iced_deferrals.defer({ @@ -670,7 +670,7 @@ (function(__iced_k) { __iced_deferrals = new iced.Deferrals(__iced_k, { parent: ___iced_passed_deferral, - filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/proofs/src/scrapers/twitter.iced", + filename: "/Users/michal/SourceCode/keybase/go/src/github.com/keybase/keybase-proofs/src/scrapers/twitter.iced", funcname: "TwitterScraper._get_body_api" }); _this._get_url_body(args, __iced_deferrals.defer({ diff --git a/src/scrapers/twitter.iced b/src/scrapers/twitter.iced index 3a82e1ca..50743315 100644 --- a/src/scrapers/twitter.iced +++ b/src/scrapers/twitter.iced @@ -148,12 +148,12 @@ exports.TwitterScraper = class TwitterScraper extends BaseScraper # twitter may not obey our matching request order if responses?.length - dict = {} + map = new Map key = if ids? then "id_str" else "screen_name" - dict[r[key]] = r for r in responses + map.set String(r[key]), r for r in responses res = [] for identifier, i in input_list - res[i] = dict[identifier] or null + res[i] = map.get(String(identifier)) or null cb err, res