From 43786681016346189f77446524152f5cbe5ac183 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 2 Sep 2026 05:53:11 +0000 Subject: [PATCH 1/3] Add private-api favorite-tags routes Four passthroughs for followed hashtags, mirroring the favorites ones: list, check, add and delete. The username comes from the validated code only. The tag is a body string, so the check and delete paths go through FavoriteTagPath, which escapes both segments and rejects dot segments, the same way NotificationsPath does. Parity harness: bodies and known-divergence entries for the additive routes. --- .../EcencyApi.Tests/FavoriteTagsPathTests.cs | 82 +++++++++++++++++ .../Handlers/PrivateApi.UserData2.cs | 88 +++++++++++++++++++ dotnet/EcencyApi/Handlers/Routes.cs | 4 + dotnet/parity/driver.py | 36 ++++++++ 4 files changed, 210 insertions(+) create mode 100644 dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs diff --git a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs new file mode 100644 index 00000000..34864799 --- /dev/null +++ b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs @@ -0,0 +1,82 @@ +using EcencyApi.Handlers; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The favorite-tag check and delete handlers interpolate a body-supplied tag into +/// an upstream path. A tag is an arbitrary string, so anything structural left +/// unescaped is re-parsed when the string becomes a Uri, and the upstream call +/// carries this service's credentials. Same reasoning as NotificationsPathTests. +/// +public class FavoriteTagsPathTests +{ + [Fact] + public void RealRequestsAreUnchanged() + { + // Hive names and tags are unreserved characters; escaping must be a no-op for + // them or this would change every live request. + Assert.Equal( + "isfavoritetag/good-karma/photography", + PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "photography")); + Assert.Equal( + "favoriteTag/user.name/contest-2026", + PrivateApi.FavoriteTagPath("favoriteTag", "user.name", "contest-2026")); + } + + [Fact] + public void LeadingHashIsEscapedNotDropped() + { + // Raw, `#` would truncate the path at a fragment. Escaped, it reaches the + // upstream, which normalises the tag and strips one leading hash itself. + Assert.Equal( + "isfavoritetag/good-karma/%23photography", + PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "#photography")); + } + + [Fact] + public void MissingTagIsTheLiteralUndefinedSegment() + { + // TemplateField renders an absent body field as "undefined", the same way the + // favorites handlers do for a missing account; it stays one plain segment. + Assert.Equal( + "favoriteTag/good-karma/undefined", + PrivateApi.FavoriteTagPath("favoriteTag", "good-karma", "undefined")); + } + + [Theory] + // A slash would add path segments and address a different resource. + [InlineData("a", "b/c")] + [InlineData("a/b", "c")] + // A question mark would truncate the path and turn the rest into a query. + [InlineData("a", "b?x=1")] + // A hash would truncate the path at a fragment. + [InlineData("a", "b#f")] + // Whitespace and non-ASCII must not leak into the request line either. + [InlineData("a", "b c")] + [InlineData("a", "caf\u00e9")] + public void StructuralCharactersCannotEscapeTheirSegment(string username, string tag) + { + var path = PrivateApi.FavoriteTagPath("isfavoritetag", username, tag); + + Assert.NotNull(path); + Assert.DoesNotContain("?x=1", path); + Assert.DoesNotContain("#f", path); + Assert.DoesNotContain(" ", path); + Assert.True(path!.All(c => c < 128)); + // The only separators left are the two this builder wrote itself. + Assert.Equal(2, path.Split('/').Length - 1); + } + + [Theory] + // Dot segments cannot be fixed by escaping: Uri decodes %2E back to `.` before it + // removes dot segments, so they have to be rejected outright. + [InlineData(".", "photography")] + [InlineData("..", "photography")] + [InlineData("good-karma", ".")] + [InlineData("good-karma", "..")] + public void DotSegmentsAreRejected(string username, string tag) + { + Assert.Null(PrivateApi.FavoriteTagPath("isfavoritetag", username, tag)); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs index 2bf92ee8..41a7928b 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs @@ -129,6 +129,94 @@ public static async Task FavoritesDelete(HttpContext ctx) await Upstream.Pipe(ApiClient.ApiRequest($"favoriteUser/{username}/{account}", HttpMethod.Delete), ctx); } + /// + /// Upstream path for a followed-tag check or removal, or null when a value cannot + /// be expressed as a single path segment. + /// + /// The tag is a body string, so one carrying `/`, `?` or `#` would otherwise be + /// re-parsed as URL structure once this string becomes a Uri, addressing a + /// different upstream resource with this service's credentials attached. Same + /// reasoning as NotificationsPath. Real tags are lowercase words with digits and + /// hyphens, which EscapeDataString leaves byte-identical. A leading `#` a client + /// may send is escaped rather than dropped: normalising the tag is the upstream's + /// job, and it strips one. + /// + public static string? FavoriteTagPath(string action, string username, string tag) + { + if (IsDotSegment(username) || IsDotSegment(tag)) + { + return null; + } + + return $"{action}/{Uri.EscapeDataString(username)}/{Uri.EscapeDataString(tag)}"; + } + + public static async Task FavoriteTags(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe( + ApiClient.ApiRequest($"favorite-tags/{username}", HttpMethod.Get, null, null, UserData2Js.Query(ctx)), + ctx); + } + + public static async Task FavoriteTagsCheck(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var tag = UserData2Js.TemplateField(body, "tag"); + var path = FavoriteTagPath("isfavoritetag", username, tag); + if (path == null) + { + await ctx.SendText(400, "Invalid tag"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx); + } + + public static async Task FavoriteTagsAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var data = new JsonObject { ["username"] = username }; + UserData2Js.CopyIfPresent(body, data, "tag"); + await Upstream.Pipe(ApiClient.ApiRequest("favorite-tag", HttpMethod.Post, null, data), ctx); + } + + public static async Task FavoriteTagsDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var tag = UserData2Js.TemplateField(body, "tag"); + var path = FavoriteTagPath("favoriteTag", username, tag); + if (path == null) + { + await ctx.SendText(400, "Invalid tag"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Delete), ctx); + } + public static async Task Fragments(HttpContext ctx) { var body = await ctx.ReadBody(); diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index dd74c8d6..9348c0a7 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -127,6 +127,10 @@ public static void Map(WebApplication app) app.MapPost("/private-api/favorites-check", PrivateApi.FavoritesCheck); app.MapPost("/private-api/favorites-add", PrivateApi.FavoritesAdd); app.MapPost("/private-api/favorites-delete", PrivateApi.FavoritesDelete); + app.MapPost("/private-api/favorite-tags", PrivateApi.FavoriteTags); + app.MapPost("/private-api/favorite-tags-check", PrivateApi.FavoriteTagsCheck); + app.MapPost("/private-api/favorite-tags-add", PrivateApi.FavoriteTagsAdd); + app.MapPost("/private-api/favorite-tags-delete", PrivateApi.FavoriteTagsDelete); app.MapPost("/private-api/fragments", PrivateApi.Fragments); app.MapPost("/private-api/fragments-add", PrivateApi.FragmentsAdd); app.MapPost("/private-api/fragments-update", PrivateApi.FragmentsUpdate); diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index c65239e3..2ee86618 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -62,6 +62,9 @@ "/private-api/account-create-friend": {"username": "newuser123", "email": "x@example.com", "friend": True}, "/private-api/subscribe": {"email": "x@example.com"}, + "/private-api/favorite-tags-check": {"code": "invalid", "tag": "photography"}, + "/private-api/favorite-tags-add": {"code": "invalid", "tag": "photography"}, + "/private-api/favorite-tags-delete": {"code": "invalid", "tag": "photography"}, "/private-api/notifications": {"code": "invalid", "filter": "", "since": 0, "limit": 50, "user": "good-karma"}, "/private-api/report": {"type": "content", "data": {"a": 1}}, @@ -224,6 +227,13 @@ def norm_body(text): "::pop and ::badcode for every POST route, which is why all three appear here." ) +FAVORITE_TAGS_DIVERGENCE = ( + "Followed-hashtag routes added after the port (the favorites passthroughs, keyed by " + "tag instead of account). The reference build has no such route and answers 404; " + "this one validates the signed code and answers 401 or proxies. Deterministic and " + "additive -- no behavior the reference ever had is changing." +) + AI_IMAGES_DIVERGENCE = ( "Per-user AI image history route added after the port (ePoints users//ai-images). " "The reference build has no such route and answers 404; this one validates the " @@ -272,6 +282,32 @@ def norm_body(text): AI_IMAGES_DIVERGENCE, "/private-api/ai-images::badcode": AI_IMAGES_DIVERGENCE, + # Followed-hashtag passthroughs, added after the port. Same shape as the entries + # above: additive routes the reference build never had. + "/private-api/favorite-tags::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags::badcode": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-check::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-check::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-check::badcode": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-add::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-add::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-add::badcode": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-delete::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-delete::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-delete::badcode": + FAVORITE_TAGS_DIVERGENCE, } # Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list From 91c0cace672021fa7d3affb39a67b7d3e4dbcab6 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 2 Sep 2026 05:59:11 +0000 Subject: [PATCH 2/3] Pin lone-surrogate encoding in FavoriteTagPath On the runtime this service targets, Uri.EscapeDataString encodes a lone surrogate as U+FFFD rather than throwing, so the check and delete handlers never fall through to the 500 page on such input. Record that as a test. --- dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs index 34864799..1ae062f9 100644 --- a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs +++ b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs @@ -68,6 +68,21 @@ public void StructuralCharactersCannotEscapeTheirSegment(string username, string Assert.Equal(2, path.Split('/').Length - 1); } + [Fact] + public void LoneSurrogateIsEncodedNotThrown() + { + // JSON strings are arbitrary UTF-16, so a lone surrogate reaches the helper as + // a real value (see TryGetStringLenient). On the runtime this service targets, + // EscapeDataString encodes it as U+FFFD rather than throwing, so the request + // stays on the handler's own path instead of falling through to the 500 page. + Assert.Equal( + "isfavoritetag/good-karma/%EF%BF%BD", + PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "\ud800")); + Assert.Equal( + "favoriteTag/good-karma/a%EF%BF%BDb", + PrivateApi.FavoriteTagPath("favoriteTag", "good-karma", "a\udc00b")); + } + [Theory] // Dot segments cannot be fixed by escaping: Uri decodes %2E back to `.` before it // removes dot segments, so they have to be rejected outright. From 8aec19dd8ca7275dfc8406d0c0c3ba6a19f25676 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 2 Sep 2026 06:12:57 +0000 Subject: [PATCH 3/3] Require a present string tag before building tag paths TemplateField turns a missing tag into the literal "undefined", and null, booleans and numbers into their JS string forms. Upstream, those are all valid tag names, so a delete with no tag would remove a follow the caller never named. FavoriteTagField now yields the tag only when it is a non-empty JSON string; check, delete and add answer 400 otherwise. --- .../EcencyApi.Tests/FavoriteTagsPathTests.cs | 34 ++++++++++++++---- .../Handlers/PrivateApi.UserData2.cs | 36 +++++++++++++++---- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs index 1ae062f9..62d22431 100644 --- a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs +++ b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using EcencyApi.Handlers; using Xunit; @@ -34,14 +35,35 @@ public void LeadingHashIsEscapedNotDropped() PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "#photography")); } + [Theory] + // A missing tag must not become the literal "undefined" the way a missing + // account does in the favorites handlers: "undefined", "null", "true" and "123" + // are all valid tag names upstream, so templating would check or delete a follow + // the caller never named. + [InlineData("{}")] + [InlineData("{\"tag\": null}")] + [InlineData("{\"tag\": true}")] + [InlineData("{\"tag\": 123}")] + [InlineData("{\"tag\": \"\"}")] + [InlineData("{\"tag\": [\"photography\"]}")] + [InlineData("{\"tag\": {\"name\": \"photography\"}}")] + public void AbsentOrNonStringTagIsRejected(string json) + { + var body = (JsonObject)JsonNode.Parse(json)!; + + Assert.Null(PrivateApi.FavoriteTagField(body)); + } + [Fact] - public void MissingTagIsTheLiteralUndefinedSegment() + public void StringTagIsReadAsIs() { - // TemplateField renders an absent body field as "undefined", the same way the - // favorites handlers do for a missing account; it stays one plain segment. - Assert.Equal( - "favoriteTag/good-karma/undefined", - PrivateApi.FavoriteTagPath("favoriteTag", "good-karma", "undefined")); + // Read leniently and passed through untouched: normalising is the upstream's job. + var body = (JsonObject)JsonNode.Parse("{\"tag\": \"#Photography\"}")!; + Assert.Equal("#Photography", PrivateApi.FavoriteTagField(body)); + + // Lone surrogates are valid JSON string content and must not be dropped. + var lone = (JsonObject)JsonNode.Parse("{\"tag\": \"\\ud800\"}")!; + Assert.Equal("\ud800", PrivateApi.FavoriteTagField(lone)); } [Theory] diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs index 41a7928b..0f77d930 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs @@ -129,6 +129,25 @@ public static async Task FavoritesDelete(HttpContext ctx) await Upstream.Pipe(ApiClient.ApiRequest($"favoriteUser/{username}/{account}", HttpMethod.Delete), ctx); } + /// + /// The body's `tag`, or null when it is absent, not a string, or empty. + /// + /// The favorites handlers template a missing `account` into the literal + /// "undefined". A tag must not go that way: "undefined", "null", "true" and + /// "123" are all valid tag names upstream, so a request with no tag, or a + /// non-string one, would check or delete a follow the caller never named. + /// String extraction is lenient on purpose: JSON strings are arbitrary UTF-16. + /// + public static string? FavoriteTagField(JsonObject body) + { + if (!body.TryGetPropertyValue("tag", out var node) || node is not JsonValue value) + { + return null; + } + + return JsVal.TryGetStringLenient(value, out var tag) && tag.Length > 0 ? tag : null; + } + /// /// Upstream path for a followed-tag check or removal, or null when a value cannot /// be expressed as a single path segment. @@ -174,8 +193,8 @@ public static async Task FavoriteTagsCheck(HttpContext ctx) await ctx.SendText(401, "Unauthorized"); return; } - var tag = UserData2Js.TemplateField(body, "tag"); - var path = FavoriteTagPath("isfavoritetag", username, tag); + var tag = FavoriteTagField(body); + var path = tag == null ? null : FavoriteTagPath("isfavoritetag", username, tag); if (path == null) { await ctx.SendText(400, "Invalid tag"); @@ -193,8 +212,13 @@ public static async Task FavoriteTagsAdd(HttpContext ctx) await ctx.SendText(401, "Unauthorized"); return; } - var data = new JsonObject { ["username"] = username }; - UserData2Js.CopyIfPresent(body, data, "tag"); + var tag = FavoriteTagField(body); + if (tag == null) + { + await ctx.SendText(400, "Invalid tag"); + return; + } + var data = new JsonObject { ["username"] = username, ["tag"] = tag }; await Upstream.Pipe(ApiClient.ApiRequest("favorite-tag", HttpMethod.Post, null, data), ctx); } @@ -207,8 +231,8 @@ public static async Task FavoriteTagsDelete(HttpContext ctx) await ctx.SendText(401, "Unauthorized"); return; } - var tag = UserData2Js.TemplateField(body, "tag"); - var path = FavoriteTagPath("favoriteTag", username, tag); + var tag = FavoriteTagField(body); + var path = tag == null ? null : FavoriteTagPath("favoriteTag", username, tag); if (path == null) { await ctx.SendText(400, "Invalid tag");