Issue
Our project has been using MongoDB
to store information such as user settings. For many years, we relied on MongoDB’s official .NET Driver
, which involved creating a client instance with a username, password, and server address to establish a direct connection to the database. Over time, the codebase incorporated numerous custom BSON Converters
to work with MongoDB
’s BsonSerializer
for serialization.
Earlier this year, India market regulations demanded that usernames and passwords must not appear in the code. To comply, we had to modify the implementation. We introduced a Node.js
middleware to relay messages between MongoDB
and the clients, with the client sending an Access Token
for Kerberos
authentication instead.
The data returned by the Node.js
middleware to the client is in JSON
format. Since rewriting all the previously configured Converters
used with the .NET Driver
would have been too costly, I made minor adjustments to reuse the existing serialization logic. After running smoothly in the test environment for some time, the changes were deployed to Pilot
, where an issue surfaced.
To simplify the problem: Suppose we have a class with a double
property stored in MongoDB
:
1
2
3
4
private class Model
{
public double Value { get; set; }
}
The Value
property is almost always an integer, so the data in the database looks like this:
1
2
3
{
"Value" : 100,
}
The error observed in the logs was an OverflowException
:
1
2
3
4
5
6
7
8
9
10
11
12
13
System.OverflowException: Value was either too large or too small for an Int64.
at System.Number.ThrowOverflowException[TInteger]()
at System.Int64.Parse(String s)
at MongoDB.Bson.IO.JsonConvert.ToInt64(String value)
at MongoDB.Bson.IO.JsonScanner.GetNumberToken(JsonBuffer buffer, Int32 firstChar)
at MongoDB.Bson.IO.JsonScanner.GetNextToken(JsonBuffer buffer)
at MongoDB.Bson.IO.JsonReader.PopToken()
at MongoDB.Bson.IO.JsonReader.ReadBsonType()
at MongoDB.Bson.Serialization.BsonClassMapSerializer`1.DeserializeClass(BsonDeserializationContext context)
at MongoDB.Bson.Serialization.BsonClassMapSerializer`1.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
at MongoDB.Bson.Serialization.IBsonSerializerExtensions.Deserialize[TValue](IBsonSerializer`1 serializer, BsonDeserializationContext context)
at MongoDB.Bson.Serialization.BsonSerializer.Deserialize[TNominalType](IBsonReader bsonReader, Action`1 configurator)
at MongoDB.Bson.Serialization.BsonSerializer.Deserialize[TNominalType](String json, Action`1 configurator)
The issue is reproducible. Upon examining the user data, it became clear that the Value
in the database was an extremely large number. A simple test confirmed this:
1
2
3
4
5
6
const string json = """
{
"Value" : 12345678901234567890,
}
""";
Assert.Throws<OverflowException>(() => BsonSerializer.Deserialize<Model>(json));
Previously, with a direct MongoDB
connection, there was no issue—despite the large number, it was within the representable range of a double
, and deserialization succeeded. My hypothesis is that in the direct connection mode, the server returns binary BSON
data with type information, so BsonSerializer
uses BsonBinaryReader
instead of JsonReader
. As a result, large numbers are parsed as double
based on the type metadata in the binary stream.
However, JsonReader
processes JSON
strings without type information and must infer the data type while parsing. When encountering a number like 12345678901234567890
without a decimal point, it assumes it’s an integer. Since this number exceeds Int64.MaxValue
, the following line calls Int64.Parse
and throws an exception:
1
2
3
4
5
6
7
8
9
10
11
var type = JsonTokenType.Int64;
...... // Character-by-character parsing to determine the type
var str = buffer.GetSubstring(start, buffer.Position - start);
if (type == JsonTokenType.Double) // type is Int64
{
var value = JsonConvert.ToDouble(str);
return new DoubleJsonToken(str, value);
}
else
{
var value = JsonConvert.ToInt64(str); // Throws OverflowException
Solution
Now that the problem is understood, how do we fix it?
The large number is technically invalid for the use case—likely stored due to a bug (since there was no validation for unknown data ranges). A seemingly simple solution is to manually correct the invalid data in the database. However, such data may be widespread and scattered, making manual fixes impractical and hard to verify. Moreover, this would only be a temporary fix—future occurrences would still trigger exceptions. A code-level solution is necessary.
Since the issue lies in an internal class MongoDB.Bson.IO.JsonScanner
, custom Converters
cannot intercept the parsing logic (the error occurs before Converter
execution). Reviewing the JsonReader
code revealed no extensibility points for custom logic. A conventional fix would require reimplementing JsonReader
entirely to modify JsonScanner
’s behavior, which is too costly. The latest MongoDB Driver
also retains this logic, suggesting either no one has reported it or the official stance is that it’s not a bug
(or not worth fixing, as it stems from user error). Thus, runtime patching (patching
) is the only viable option.
The chosen approach uses Harmony. I write a method IsBadInt64String
to check if a given string can be safely converted to Int64
. Then modify the IL
instructions of JsonScanner.GetNumberToken
to insert a call to IsBadInt64String
, effectively changing the logic to:
1
2
3
4
5
6
// Even if type is not Double, treat as Double if str cannot be safely converted to Int64
if (type == JsonTokenType.Double || IsBadInt64String(str))
{
var value = JsonConvert.ToDouble(str);
return new DoubleJsonToken(str, value);
}
The issue is now resolved. Of course, we should also investigate how such large numbers were stored in the first place—perhaps adding validation against Int64.MaxValue
.
The implementation details are omitted here, but the core reference code is below. For the full implementation, visit GitHub.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private static void AddIsBadInt64StringCheck(CodeMatcher codeMatcher, object subString, Label int64Label, ILGenerator il)
{
/*
IL_0014: ldloc.0 // 'type'
IL_0015: ldc.i4.s 10 // 0x0a //JsonTokenType.Double
IL_0017: beq.s IL_0021 //isNotDoubleLabel
IL_0019: ldloc.s, 7 // substring
IL_001a: call bool BsonInt64DoubleFix.JsonScannerFix/Impl::IsBadInt64String(string)
IL_001f: br.s IL_0022 //isBadInt64Label
IL_0021: ldc.i4.1 //isNotDoubleLabel
IL_0022: stloc.2 // V_2 //isBadInt64Label
IL_0023: ldloc.2 // V_2
IL_0024: brfalse.s IL_0039 //int64Label
*/
var isNotDoubleLabel = il.DefineLabel();
var isBadInt64Label = il.DefineLabel();
codeMatcher.InsertAndAdvance(
Code(Beq_S, isNotDoubleLabel),
Code(Ldloc_S, subString),
Code(Call, typeof(Impl).GetMethod(nameof(IsBadInt64String), Static | NonPublic)),
Code(Br_S, isBadInt64Label),
Code(Ldc_I4_1).WithLabels(isNotDoubleLabel),
Code(Stloc_2).WithLabels(isBadInt64Label),
Code(Ldloc_2),
Code(Brfalse_S, int64Label)
);
}
问题
项目中一直使用MongoDB
来保存诸如用户设置等信息。以前用了挺多年的官方.net Driver
,也就是通过用户名、密码以及服务器地址创建客户端实例来直联数据库。这些年下来代码中也引入了不少自定义的BSON Converter
配合MongoDB
的BsonSerializer
处理序列化。
今年初某个自认为领先东大20
年的大国市场要求代码中不能出现用户名和密码,于是被要求修改代码以便合规。我们引入了一个node.js
中间件,负责在MongoDB
和客户端之间转发消息,客户端则发送Access Token
通过Kerberos
进行身份认证。
客户端从和node.js
中间件取回的数据是JSON
,之前使用Driver
的时候引入的各种配置以及Converter
扔掉重写代价太大,我只做了一些小改动来重用已有的所有代码处理序列化。代码在测试环境跑了不少时间没问题后进了Pilot
,然后一个问题冒了出来。
把问题简化一下大致是这样:假设有下面这样一个包含double
类型属性的类被保存到MongoDB
。
1
2
3
4
private class Model
{
public double Value { get; set; }
}
这个属性Value
的值几乎总是整数,在数据库中看到是类似这样:
1
2
3
{
"Value" : 100,
}
报错后在日志中看到是OverflowException
:
1
2
3
4
5
6
7
8
9
10
11
12
13
System.OverflowException: Value was either too large or too small for an Int64.
at System.Number.ThrowOverflowException[TInteger]()
at System.Int64.Parse(String s)
at MongoDB.Bson.IO.JsonConvert.ToInt64(String value)
at MongoDB.Bson.IO.JsonScanner.GetNumberToken(JsonBuffer buffer, Int32 firstChar)
at MongoDB.Bson.IO.JsonScanner.GetNextToken(JsonBuffer buffer)
at MongoDB.Bson.IO.JsonReader.PopToken()
at MongoDB.Bson.IO.JsonReader.ReadBsonType()
at MongoDB.Bson.Serialization.BsonClassMapSerializer`1.DeserializeClass(BsonDeserializationContext context)
at MongoDB.Bson.Serialization.BsonClassMapSerializer`1.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
at MongoDB.Bson.Serialization.IBsonSerializerExtensions.Deserialize[TValue](IBsonSerializer`1 serializer, BsonDeserializationContext context)
at MongoDB.Bson.Serialization.BsonSerializer.Deserialize[TNominalType](IBsonReader bsonReader, Action`1 configurator)
at MongoDB.Bson.Serialization.BsonSerializer.Deserialize[TNominalType](String json, Action`1 configurator)
可以稳定重现,把用户数据拿过来一跑就发现数据库中Value
的值是一个很大的数,写一行简单的测试代码即可验证。
1
2
3
4
5
6
const string json = """
{
"Value" : 12345678901234567890,
}
""";
Assert.Throws<OverflowException>(() => BsonSerializer.Deserialize<Model>(json));
以前直联MongoDB
没有问题,数虽大,但仍在double
可以表示的范围内,反序列化是成功的。我推测直联MongoDB
方式下服务器返回的二进制数据流BSON
带有格式信息,对二进制数据BsonSerializer
会调用BsonBinaryReader
而不是JsonReader
来读取数据,这样一来大数在数据流中会根据类型消息被解析成double
。
而JsonReader
读取的Json
字符串没有格式信息,只能边读数据边判断数据类型,读到逗号结束时上面的大数因为没有包含任何浮点数表示形式被认为是整数,但其表示的整数超出了Int64.MaxValue
,于是下面代码的最后一行调用Int64.Parse
抛了异常。
1
2
3
4
5
6
7
8
9
10
11
var type = JsonTokenType.Int64;
......//逐字读取并判断数据类型
var str = buffer.GetSubstring(start, buffer.Position - start);
if (type == JsonTokenType.Double) //type是Int64
{
var value = JsonConvert.ToDouble(str);
return new DoubleJsonToken(str, value);
}
else
{
var value = JsonConvert.ToInt64(str); //抛出OverflowException
解决办法
问题差不多弄清楚,怎么解决呢?
这个大数其实对相关使用场景来说并不合法,也许是某个bug
导致存进去了(因其数据范围未知没有validation
)。一个看起来简单的办法是直接操作数据库改正非法数据,但类似的数据可能很多并且分散在不同的地方,所以修改数据难度其实非常大,而且很难验证是否改对;其次是治标不治本,以后再出现这样的问题还是会抛异常,问题仍然存在。还是得从代码层面解决问题。
由于问题出在一个内部类MongoDB.Bson.IO.JsonScanner
里面,没法通过自定义的Converter
接管数据解析(还没有执行到调用converter
的那一步)。大致看了JsonReader
的代码,也没发现可供插入自定义逻辑的空间,要从常规方式动手的话得从头到尾实现一个JsonReader
才能触摸到JsonScanner
那段抛异常的代码,代价过高。最新的MongoDB Driver
里这段逻辑也没有变化,可见要么没人报告过要么官方认为这不是一个bug
(毕竟是用户错误)或者不值得处理。那么就只能patch
代码了。
我选择的方案是用Harmony。写一个方法IsBadInt64String
来判断给定字符串是否可以转换为Int64
,在运行时修改JsonScanner.GetNumberToken
的IL
指令插入对IsBadInt64String
的调用,把上面的代码改成逻辑上等价于下面这样:
1
2
3
4
5
6
//即使type不是Double,如果str不能安全转换为Int64仍然按Double处理
if (type == JsonTokenType.Double || IsBadInt64String(str))
{
var value = JsonConvert.ToDouble(str);
return new DoubleJsonToken(str, value);
}
问题算是完美解决,当然还得找找那么大的数是怎么来的,至少加个针对Int64.MaxValue
的验证吧……
具体过程不赘述,核心参考代码如下,具体实现请移步github。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private static void AddIsBadInt64StringCheck(CodeMatcher codeMatcher, object subString, Label int64Label, ILGenerator il)
{
/*
IL_0014: ldloc.0 // 'type'
IL_0015: ldc.i4.s 10 // 0x0a //JsonTokenType.Double
IL_0017: beq.s IL_0021 //isNotDoubleLabel
IL_0019: ldloc.s, 7 // substring
IL_001a: call bool BsonInt64DoubleFix.JsonScannerFix/Impl::IsBadInt64String(string)
IL_001f: br.s IL_0022 //isBadInt64Label
IL_0021: ldc.i4.1 //isNotDoubleLabel
IL_0022: stloc.2 // V_2 //isBadInt64Label
IL_0023: ldloc.2 // V_2
IL_0024: brfalse.s IL_0039 //int64Label
*/
var isNotDoubleLabel = il.DefineLabel();
var isBadInt64Label = il.DefineLabel();
codeMatcher.InsertAndAdvance(
Code(Beq_S, isNotDoubleLabel),
Code(Ldloc_S, subString),
Code(Call, typeof(Impl).GetMethod(nameof(IsBadInt64String), Static | NonPublic)),
Code(Br_S, isBadInt64Label),
Code(Ldc_I4_1).WithLabels(isNotDoubleLabel),
Code(Stloc_2).WithLabels(isBadInt64Label),
Code(Ldloc_2),
Code(Brfalse_S, int64Label)
);
}