From 861a172aa844d0c6b585ba7830c5179b24c44f7c Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 17 Sep 2026 12:42:46 +0000 Subject: [PATCH] fix(private-api): device routes only act on the signed-in account register-device and detail-device now require the body username to be the account the code belongs to (case-insensitive, like the notifications feed), and answer 403 otherwise without calling upstream. Code validation and the upstream call get test seams, as the curation desk routes have. --- .../DeviceAuthorizationTests.cs | 148 ++++++++++++++++++ .../Handlers/PrivateApi.UserData1.cs | 38 ++++- 2 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/DeviceAuthorizationTests.cs diff --git a/dotnet/EcencyApi.Tests/DeviceAuthorizationTests.cs b/dotnet/EcencyApi.Tests/DeviceAuthorizationTests.cs new file mode 100644 index 00000000..c4b355ff --- /dev/null +++ b/dotnet/EcencyApi.Tests/DeviceAuthorizationTests.cs @@ -0,0 +1,148 @@ +using EcencyApi.Handlers; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// /private-api/register-device and /private-api/detail-device act on a push +/// registration, which belongs to the account the code was issued for. +/// +public class DeviceAuthorizationTests +{ + [Theory] + [InlineData("good-karma")] + // Hive names are lowercase, but the comparison must not hinge on that. + [InlineData("Good-Karma")] + [InlineData("GOOD-KARMA")] + public void TheCodesOwnAccountIsAccepted(string requested) + { + Assert.True(PrivateApi.IsOwnDeviceRequest("good-karma", requested)); + } + + [Theory] + [InlineData("someone-else")] + [InlineData("good-karm")] + [InlineData("good-karma2")] + [InlineData(" good-karma")] + [InlineData("good_karma")] + public void AnyOtherAccountIsRefused(string requested) + { + Assert.False(PrivateApi.IsOwnDeviceRequest("good-karma", requested)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void AMissingUsernameIsRefused(string? requested) + { + Assert.False(PrivateApi.IsOwnDeviceRequest("good-karma", requested)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void WithoutAValidatedCodeNothingIsAccepted(string? validated) + { + Assert.False(PrivateApi.IsOwnDeviceRequest(validated, "good-karma")); + Assert.False(PrivateApi.IsOwnDeviceRequest(validated, validated)); + } +} + +/// +/// The device handlers themselves: a code for one account cannot register or read a +/// device row under another, and nothing reaches upstream when it tries. +/// +[Collection("device-auth")] +public class DeviceHandlerTests : IDisposable +{ + private readonly CurationDeskTestSupport.Recorder _upstream = new(); + + public DeviceHandlerTests() + { + // `code` "as:alice" validates as alice; anything else is invalid. + PrivateApi.DeviceValidateCode = body => + { + var code = body["code"]?.GetValue(); + return Task.FromResult(code != null && code.StartsWith("as:", StringComparison.Ordinal) ? code[3..] : null); + }; + PrivateApi.DeviceUpstream = (endpoint, method, payload) => + _upstream.Handle(endpoint, method, Array.Empty>(), payload); + } + + public void Dispose() + { + PrivateApi.DeviceValidateCode = PrivateApi.ValidateCode; + PrivateApi.DeviceUpstream = (endpoint, method, payload) => + EcencyApi.Infrastructure.ApiClient.ApiRequest(endpoint, method, null, payload); + } + + private static string Register(string code, string? username) => + username == null + ? $$"""{"code":"{{code}}","token":"t1","system":"fcm-ios","allows_notify":1,"notify_types":[1]}""" + : $$"""{"code":"{{code}}","username":"{{username}}","token":"t1","system":"fcm-ios","allows_notify":1,"notify_types":[1]}"""; + + [Theory] + [InlineData("alice")] + [InlineData("Alice")] + public async Task RegisteringTheCodesOwnAccountIsForwarded(string username) + { + var ctx = CurationDeskTestSupport.Post("/private-api/register-device", Register("as:alice", username)); + await PrivateApi.RegisterDevice(ctx); + + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_upstream.Calls); + Assert.Equal("rgstrmbldvc/", call.Endpoint); + Assert.Equal(HttpMethod.Post, call.Method); + Assert.Equal(username, call.Payload?["username"]?.GetValue()); + Assert.Equal("t1", call.Payload?["token"]?.GetValue()); + } + + [Theory] + [InlineData("bob")] + [InlineData(null)] + public async Task RegisteringAnotherAccountIsRefused(string? username) + { + var ctx = CurationDeskTestSupport.Post("/private-api/register-device", Register("as:alice", username)); + await PrivateApi.RegisterDevice(ctx); + + Assert.Equal(403, ctx.Response.StatusCode); + Assert.Empty(_upstream.Calls); + } + + [Fact] + public async Task RegisteringWithoutAValidCodeIsUnauthorized() + { + var ctx = CurationDeskTestSupport.Post("/private-api/register-device", Register("bogus", "alice")); + await PrivateApi.RegisterDevice(ctx); + + Assert.Equal(401, ctx.Response.StatusCode); + Assert.Empty(_upstream.Calls); + } + + [Fact] + public async Task ReadingTheCodesOwnDeviceIsForwarded() + { + var ctx = CurationDeskTestSupport.Post( + "/private-api/detail-device", """{"code":"as:alice","username":"alice","token":"t1"}"""); + await PrivateApi.DetailDevice(ctx); + + Assert.Equal(200, ctx.Response.StatusCode); + var call = Assert.Single(_upstream.Calls); + Assert.Equal("mbldvcdtl/alice/t1", call.Endpoint); + Assert.Equal(HttpMethod.Get, call.Method); + } + + [Fact] + public async Task ReadingAnotherAccountsDeviceIsRefused() + { + var ctx = CurationDeskTestSupport.Post( + "/private-api/detail-device", """{"code":"as:alice","username":"bob","token":"t1"}"""); + await PrivateApi.DetailDevice(ctx); + + Assert.Equal(403, ctx.Response.StatusCode); + Assert.Empty(_upstream.Calls); + } +} + +[CollectionDefinition("device-auth", DisableParallelization = true)] +public class DeviceAuthCollection { } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs index f99969d2..5d8e0c14 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs @@ -41,6 +41,22 @@ public static (string? Username, bool FullScope) ResolveNotificationsTarget( string.Equals(requestedUser, validatedUsername, StringComparison.OrdinalIgnoreCase)); } + /// + /// Whether a device request may act on `requestedUsername`. A push registration + /// belongs to the account the code was issued for, so the device endpoints only + /// accept that account. Same case rule as ResolveNotificationsTarget. + /// + public static bool IsOwnDeviceRequest(string? validatedUsername, string? requestedUsername) => + !string.IsNullOrEmpty(validatedUsername) + && string.Equals(requestedUsername, validatedUsername, StringComparison.OrdinalIgnoreCase); + + /// Signed-code validation for the device routes, replaceable for tests (no chain RPC). + internal static Func> DeviceValidateCode = ValidateCode; + + /// The device routes' upstream call, replaceable so tests can observe it. + internal static Func> DeviceUpstream = + (endpoint, method, payload) => ApiClient.ApiRequest(endpoint, method, null, payload); + /// Header enotify reads the shared secret from. public const string EnotifyInternalTokenHeader = "X-Ecency-Internal-Token"; @@ -193,37 +209,49 @@ public static async Task MarkNotifications(HttpContext ctx) public static async Task RegisterDevice(HttpContext ctx) { var body = await ctx.ReadBody(); - var authedUsername = await ValidateCode(body); + var authedUsername = await DeviceValidateCode(body); if (string.IsNullOrEmpty(authedUsername)) { await ctx.SendText(401, "Unauthorized"); return; } - // Payload takes the fields from the request body, not the authed user. + if (!IsOwnDeviceRequest(authedUsername, body.Str("username"))) + { + await ctx.SendText(403, "Forbidden"); + return; + } + + // Payload takes the fields from the request body; username matches the authed user. var data = new JsonObject(); UserData1Helpers.CopyIfPresent(data, body, "username"); UserData1Helpers.CopyIfPresent(data, body, "token"); UserData1Helpers.CopyIfPresent(data, body, "system"); UserData1Helpers.CopyIfPresent(data, body, "allows_notify"); UserData1Helpers.CopyIfPresent(data, body, "notify_types"); - await Upstream.Pipe(ApiClient.ApiRequest("rgstrmbldvc/", HttpMethod.Post, null, data), ctx); + await Upstream.Pipe(DeviceUpstream("rgstrmbldvc/", HttpMethod.Post, data), ctx); } // POST ^/private-api/detail-device$ public static async Task DetailDevice(HttpContext ctx) { var body = await ctx.ReadBody(); - var authedUsername = await ValidateCode(body); + var authedUsername = await DeviceValidateCode(body); if (string.IsNullOrEmpty(authedUsername)) { await ctx.SendText(401, "Unauthorized"); return; } + if (!IsOwnDeviceRequest(authedUsername, body.Str("username"))) + { + await ctx.SendText(403, "Forbidden"); + return; + } + var username = UserData1Helpers.TemplateOf(body, "username"); var token = UserData1Helpers.TemplateOf(body, "token"); - await Upstream.Pipe(ApiClient.ApiRequest($"mbldvcdtl/{username}/{token}", HttpMethod.Get), ctx); + await Upstream.Pipe(DeviceUpstream($"mbldvcdtl/{username}/{token}", HttpMethod.Get, null), ctx); } // POST ^/private-api/images$