Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions dotnet/EcencyApi.Tests/JsValTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ public void NumberCoerce_MatchesJs(string input, double expected)
else Assert.Equal(expected, actual);
}

[Fact]
public void LoneSurrogates_ExtractAndSerializeLikeJs()
{
// JSON.parse accepts lone surrogate escapes (a JS string is arbitrary
// UTF-16); System.Text.Json throws InvalidOperationException when
// materializing such strings, which turned payloads the Node service
// handled fine into 500s. The lenient path must extract them, and
// Stringify must re-emit the escape like well-formed JSON.stringify.
var node = JsonNode.Parse("{\"app\":\"x\\ud83dy\"}")!;

var s = JsVal.AsString(node["app"]);
Assert.NotNull(s);
Assert.Equal(3, s!.Length);
Assert.Equal('\ud83d', s[1]);

Assert.Equal("{\"app\":\"x\\ud83dy\"}", JsJson.Stringify(node));

// ToJsString (template-literal coercion) must survive it too
Assert.Equal("x\ud83dy", JsVal.ToJsString(node["app"]));
}

[Fact]
public void ToJsString_CoercesLikeJs()
{
Expand Down
5 changes: 5 additions & 0 deletions dotnet/EcencyApi.Tests/fixtures/crypto-vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,11 @@
}
],
"validateCodeRaw": [
{
"tokenJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"x\\ud83dy\"},\"authors\":[\"mallory\"],\"timestamp\":1752300000,\"signatures\":[\"ab\"]}",
"rawMessage": "{\"signed_message\":{\"type\":\"code\",\"app\":\"x\\ud83dy\"},\"authors\":[\"mallory\"],\"timestamp\":1752300000}",
"digestHex": "6162f04277a85ad43b614e076d81a317ad873450ff71176ff4715ea0844b5e78"
},
{
"tokenJson": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000,\"signatures\":[\"ab\"]}",
"rawMessage": "{\"signed_message\":{\"type\":\"code\",\"app\":\"ecency.app\"},\"authors\":[\"alice\"],\"timestamp\":1751900000}",
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/AuthApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ private static string JsToString(JsonNode? node)
// Array.prototype.toString: elements joined with ",", null -> ""
return string.Join(",", arr.Select(e => e == null ? "" : JsToString(e)));
case JsonValue v:
if (v.TryGetValue<string>(out var s)) return s;
if (JsVal.TryGetStringLenient(v, out var s)) return s;
if (v.TryGetValue<bool>(out var b)) return b ? "true" : "false";
return JsJson.Stringify(v);
default:
Expand Down
8 changes: 4 additions & 4 deletions dotnet/EcencyApi/Handlers/HiveExplorer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public static double ParseToken(JsonNode? val)
return 0d;
}

if (val is not JsonValue jv || !jv.TryGetValue<string>(out var s))
if (val is not JsonValue jv || !JsVal.TryGetStringLenient(jv, out var s))
{
return 0d;
}
Expand Down Expand Up @@ -166,7 +166,7 @@ private static double ParseFloatJs(string s)
// TypeError), which the outer catch rewrites to "Failed to get globalProps".
private static double GetVestAmount(JsonNode? value)
{
if (value is JsonValue jv && jv.TryGetValue<string>(out _))
if (value is JsonValue jv && JsVal.TryGetStringLenient(jv, out _))
{
return ParseToken(value);
}
Expand Down Expand Up @@ -238,7 +238,7 @@ private static double JsNumber(JsonNode? node)
{
return double.NaN; // "true"/"false" don't parse
}
if (ev.TryGetValue<string>(out var es))
if (JsVal.TryGetStringLenient(ev, out var es))
{
return JsNumberString(es);
}
Expand All @@ -252,7 +252,7 @@ private static double JsNumber(JsonNode? node)
{
return b ? 1d : 0d;
}
if (value.TryGetValue<string>(out var str))
if (JsVal.TryGetStringLenient(value, out var str))
{
return JsNumberString(str);
}
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.Accounts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public static string SignupClientIp(HttpContext ctx)
private static async Task<bool> VerifyTurnstile(JsonNode? token, string ip)
{
var secret = Config.TurnstileSecret;
var tokenStr = token is JsonValue tv && tv.TryGetValue<string>(out string? ts) ? ts : null;
var tokenStr = token is JsonValue tv && JsVal.TryGetStringLenient(tv, out string? ts) ? ts : null;
if (string.IsNullOrEmpty(secret) || tokenStr == null || tokenStr.Trim().Length == 0)
{
return false;
Expand Down
6 changes: 3 additions & 3 deletions dotnet/EcencyApi/Handlers/PrivateApi.Chain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ private static string RpcErrorMessage(JsonNode? errorNode, string fallback)

private static string ParseHexBalance(JsonNode? value)
{
if (value is not JsonValue jsonValue || !jsonValue.TryGetValue<string>(out var text))
if (value is not JsonValue jsonValue || !JsVal.TryGetStringLenient(jsonValue, out var text))
{
throw new Exception("Invalid hexadecimal balance response");
}
Expand Down Expand Up @@ -595,7 +595,7 @@ private static async Task<string> GetBlockstreamAccessToken()
var data = resp.BodyIsJson ? resp.Json : JsonValue.Create(resp.RawText);
var obj = data as JsonObject;

var accessToken = obj?["access_token"] is JsonValue tokenValue && tokenValue.TryGetValue<string>(out var tokenText)
var accessToken = obj?["access_token"] is JsonValue tokenValue && JsVal.TryGetStringLenient(tokenValue, out var tokenText)
? tokenText
: null;

Expand Down Expand Up @@ -1107,7 +1107,7 @@ public static async Task ChainRpc(HttpContext ctx)
null => "null",
JsonObject => "[object Object]",
JsonArray arr => string.Join(",", arr.Select(ChainJsString)),
JsonValue v when v.TryGetValue<string>(out var s) => s,
JsonValue v when JsVal.TryGetStringLenient(v, out var s) => s,
_ => JsJson.Stringify(node),
};

Expand Down
8 changes: 4 additions & 4 deletions dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static partial class PrivateApi
{
var hasCode = body.TryGetPropertyValue("code", out var codeNode);
string? code = null;
if (codeNode is JsonValue codeValue && codeValue.TryGetValue<string>(out var codeStr))
if (codeNode is JsonValue codeValue && JsVal.TryGetStringLenient(codeValue, out var codeStr))
{
code = codeStr;
}
Expand Down Expand Up @@ -98,7 +98,7 @@ public static partial class PrivateApi

string? author = null;
if (authors is { Count: > 0 } && authors[0] is JsonValue authorValue
&& authorValue.TryGetValue<string>(out var authorStr))
&& JsVal.TryGetStringLenient(authorValue, out var authorStr))
{
author = authorStr;
}
Expand All @@ -109,7 +109,7 @@ timestampNode is JsonValue timestampValue

string? signature = null;
if (signatures is { Count: > 0 } && signatures[0] is JsonValue signatureValue
&& signatureValue.TryGetValue<string>(out var signatureStr))
&& JsVal.TryGetStringLenient(signatureValue, out var signatureStr))
{
signature = signatureStr;
}
Expand Down Expand Up @@ -210,7 +210,7 @@ timestampNode is JsonValue timestampValue
}

var first = item[0]; // throws for non-indexable nodes, like JS destructuring a non-iterable
keys.Add(first is JsonValue v && v.TryGetValue<string>(out var key) ? key : null);
keys.Add(first is JsonValue v && JsVal.TryGetStringLenient(v, out var key) ? key : null);
}

return keys;
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ private static string FeedsJsToString(JsonNode? node)
JsonObject => "[object Object]",
_ => FeedsJsToString(e),
}));
case JsonValue v when v.TryGetValue<string>(out var s):
case JsonValue v when JsVal.TryGetStringLenient(v, out var s):
return s;
case JsonValue v when v.TryGetValue<bool>(out var b):
return b ? "true" : "false";
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ private static string MiscJsString(JsonNode? node)
}
if (node is JsonValue v)
{
if (v.TryGetValue<string>(out var s))
if (JsVal.TryGetStringLenient(v, out var s))
{
return s;
}
Expand Down
10 changes: 5 additions & 5 deletions dotnet/EcencyApi/Handlers/PrivateApi.Payments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public static async Task StripeCreateIntent(HttpContext ctx)
// Reject a malformed value at this boundary before forwarding (typeof guard first:
// a non-string must never be coerced by the regex test and forwarded).
var hostingTargetPresent = body.TryGetPropertyValue("hosting_target", out var hostingTargetNode);
var hostingTarget = hostingTargetNode is JsonValue htv && htv.TryGetValue<string>(out var hts)
var hostingTarget = hostingTargetNode is JsonValue htv && JsVal.TryGetStringLenient(htv, out var hts)
? hts
: null;
if (hostingTargetPresent && (hostingTarget == null || !PaymentsHiveNameRegex.IsMatch(hostingTarget)))
Expand All @@ -160,7 +160,7 @@ public static async Task StripeCreateIntent(HttpContext ctx)
// lowercase, so normalize typed input (trim + lowercase) before validating so a
// recipient like "Alice" is accepted as "alice", and forward the canonical form.
var giftRecipientPresent = body.TryGetPropertyValue("gift_recipient", out var giftRecipientNode);
var giftRecipient = giftRecipientNode is JsonValue grv && grv.TryGetValue<string>(out var grs)
var giftRecipient = giftRecipientNode is JsonValue grv && JsVal.TryGetStringLenient(grv, out var grs)
? grs.Trim().ToLowerInvariant()
: null;
if (giftRecipientPresent && (giftRecipient == null || !PaymentsHiveNameRegex.IsMatch(giftRecipient)))
Expand All @@ -169,7 +169,7 @@ public static async Task StripeCreateIntent(HttpContext ctx)
return;
}
var giftMessagePresent = body.TryGetPropertyValue("gift_message", out var giftMessageNode);
var giftMessage = giftMessageNode is JsonValue gmv && gmv.TryGetValue<string>(out var gms)
var giftMessage = giftMessageNode is JsonValue gmv && JsVal.TryGetStringLenient(gmv, out var gms)
? gms
: null;
if (giftMessagePresent && (giftMessage == null || giftMessage.Length > 200))
Expand Down Expand Up @@ -392,7 +392,7 @@ private static string PaymentsSignupClientIp(HttpContext ctx)
private static async Task<bool> PaymentsVerifyTurnstile(JsonNode? token, string ip)
{
var secret = Config.TurnstileSecret;
var tokenStr = token is JsonValue tv && tv.TryGetValue<string>(out var ts) ? ts : null;
var tokenStr = token is JsonValue tv && JsVal.TryGetStringLenient(tv, out var ts) ? ts : null;
if (string.IsNullOrEmpty(secret) || tokenStr == null || tokenStr.Trim().Length == 0)
{
return false;
Expand Down Expand Up @@ -474,7 +474,7 @@ private static string PaymentsJsToString(JsonNode? node)
case JsonObject:
return "[object Object]";
case JsonValue value:
if (value.TryGetValue<string>(out var s))
if (JsVal.TryGetStringLenient(value, out var s))
{
return s;
}
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ public static string Template(JsonNode? node)
// Array.prototype.toString: elements joined by ",", holes/null -> ""
return string.Join(",", arr.Select(item => item == null ? "" : Template(item)));
default:
if (node is JsonValue jv && jv.TryGetValue<string>(out var s))
if (node is JsonValue jv && JsVal.TryGetStringLenient(jv, out var s))
{
return s;
}
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ private static string JsString(JsonNode? node)
// Array.prototype.toString: join(","), null/undefined elements -> ""
return string.Join(",", arr.Select(el => el == null ? "" : JsString(el)));
case JsonValue v:
if (v.TryGetValue<string>(out var s)) return s;
if (JsVal.TryGetStringLenient(v, out var s)) return s;
if (v.TryGetValue<bool>(out var b)) return b ? "true" : "false";
// numbers: String(n) == JSON.stringify(n) for finite doubles
return JsJson.Stringify(node);
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Handlers/SearchApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ private static string JsToString(JsonNode? node)
// Array.prototype.toString: elements joined with ",", null -> ""
return string.Join(",", arr.Select(e => e == null ? "" : JsToString(e)));
case JsonValue v:
if (v.TryGetValue<string>(out var s)) return s;
if (JsVal.TryGetStringLenient(v, out var s)) return s;
if (v.TryGetValue<bool>(out var b)) return b ? "true" : "false";
return JsJson.Stringify(v);
default:
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Infrastructure/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public ApiAuthException() : base("Api auth couldn't be create!") { }
{
headers[kv.Key] = kv.Value switch
{
JsonValue v when v.TryGetValue<string>(out var s) => s,
JsonValue v when JsVal.TryGetStringLenient(v, out var s) => s,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Surrogate Header Values Forwarded

When an auth config value contains a lone surrogate escape, this branch now decodes it into a raw .NET string and forwards it as an HTTP header value. Header values cannot safely carry isolated UTF-16 surrogates, so the private API request can fail later with malformed or rejected headers instead of rejecting the bad auth configuration at this boundary.

Fix in Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value comes from PRIVATE_API_AUTH — operator-controlled deploy configuration (a base64-encoded JSON header map we set ourselves), not client input, so a lone surrogate here means broken config rather than a reachable attack surface. And the failure mode is identical either way: a header value containing a surrogate (or any non-ASCII) makes the HTTP client throw at send time in both runtimes (SocketsHttpHandler rejects non-ASCII header values; Node http throws ERR_INVALID_CHAR), which pipe() turns into the same 500. Rejecting earlier at this boundary would add validation for a config state that fails loudly on the first request regardless.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair point. The failure boundary argument doesn't hold here — SocketsHttpHandler throws on send regardless, and since PRIVATE_API_AUTH is operator config, a surrogate there is broken config that surfaces immediately on the first request. Early rejection at this boundary adds no safety net that doesn't already exist. The comment stands withdrawn.

null => "null",
_ => kv.Value.ToJsonString(),
};
Expand Down
2 changes: 1 addition & 1 deletion dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public static async Task SendJson(this HttpContext ctx, int status, JsonNode? no

/// <summary>Convenience: string body field (undefined -> null).</summary>
public static string? Str(this JsonObject body, string key) =>
body.TryGetPropertyValue(key, out var v) && v is JsonValue val && val.TryGetValue<string>(out var s)
body.TryGetPropertyValue(key, out var v) && v is JsonValue val && JsVal.TryGetStringLenient(val, out var s)
? s
: null;

Expand Down
4 changes: 2 additions & 2 deletions dotnet/EcencyApi/Infrastructure/JsJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ private static void WriteValue(StringBuilder sb, JsonNode? node)

private static void WritePrimitive(StringBuilder sb, JsonValue value)
{
if (value.TryGetValue<string>(out var s))
if (JsVal.TryGetStringLenient(value, out var s))
{
WriteString(sb, s);
return;
Expand Down Expand Up @@ -260,7 +260,7 @@ public static bool IsTruthy(JsonNode? node)
case JsonArray:
return true;
case JsonValue v:
if (v.TryGetValue<string>(out var s)) return s.Length > 0;
if (JsVal.TryGetStringLenient(v, out var s)) return s.Length > 0;
if (v.TryGetValue<bool>(out var b)) return b;
// Parsed values are element-backed; programmatically built ones
// are CLR-backed and would throw on GetValue<JsonElement>().
Expand Down
71 changes: 68 additions & 3 deletions dotnet/EcencyApi/Infrastructure/JsVal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static class JsVal
public static bool IsString(JsonNode? n) =>
n is JsonValue v && v.TryGetValue<JsonElement>(out var e)
? e.ValueKind == JsonValueKind.String
: n is JsonValue v2 && v2.TryGetValue<string>(out _);
: n is JsonValue v2 && JsVal.TryGetStringLenient(v2, out _);

public static bool IsNumber(JsonNode? n)
{
Expand All @@ -41,12 +41,77 @@ public static bool IsBool(JsonNode? n)

// ---- accessors -------------------------------------------------------

/// <summary>
/// String extraction tolerant of lone-surrogate \u escapes. JavaScript's
/// JSON.parse accepts them (JS strings are arbitrary UTF-16), but
/// System.Text.Json throws InvalidOperationException when materializing
/// such strings — which turned payloads the Node service handled fine into
/// 500s. Falls back to manually unescaping the raw JSON text with JS
/// semantics (\uXXXX decodes to the code unit, paired or not).
/// </summary>
public static bool TryGetStringLenient(JsonValue v, out string value)
{
try
{
if (v.TryGetValue<string>(out value!))
{
return true;
}
}
catch (InvalidOperationException)
{
// incomplete surrogate pair in the JSON text; fall through
}

if (v.TryGetValue<JsonElement>(out var el) && el.ValueKind == JsonValueKind.String)
{
value = UnescapeJsonStringLenient(el.GetRawText());
return true;
}

value = null!;
return false;
}

/// <summary>Unescape a raw JSON string literal (including quotes) with JS semantics.</summary>
internal static string UnescapeJsonStringLenient(string raw)
{
var sb = new StringBuilder(raw.Length);
for (var i = 1; i < raw.Length - 1; i++)
{
var c = raw[i];
if (c != '\\')
{
sb.Append(c);
continue;
}
i++;
switch (raw[i])
{
case '"': sb.Append('"'); break;
case '\\': sb.Append('\\'); break;
case '/': sb.Append('/'); break;
case 'b': sb.Append('\b'); break;
case 'f': sb.Append('\f'); break;
case 'n': sb.Append('\n'); break;
case 'r': sb.Append('\r'); break;
case 't': sb.Append('\t'); break;
case 'u':
sb.Append((char)ushort.Parse(raw.AsSpan(i + 1, 4),
NumberStyles.HexNumber, CultureInfo.InvariantCulture));
i += 4;
break;
}
}
return sb.ToString();
}

/// <summary>The JSON string value, or null when the node is not a string.</summary>
public static string? AsString(JsonNode? n)
{
if (n is JsonValue v)
{
if (v.TryGetValue<string>(out var s)) return s;
if (JsVal.TryGetStringLenient(v, out var s)) return s;
if (v.TryGetValue<JsonElement>(out var e) && e.ValueKind == JsonValueKind.String)
return e.GetString();
}
Expand Down Expand Up @@ -180,7 +245,7 @@ public static string ToJsString(JsonNode? n)
case JsonObject:
return "[object Object]";
case JsonValue v:
if (v.TryGetValue<string>(out var s)) return s;
if (JsVal.TryGetStringLenient(v, out var s)) return s;
if (v.TryGetValue<bool>(out var b)) return b ? "true" : "false";
var num = AsNumber(n);
if (num.HasValue) return JsNumberToString(num.Value);
Expand Down
Loading
Loading