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$