From d80cd42a0c7238e89a460ed149e20c34780538dc Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 11:02:21 +0200 Subject: [PATCH] Rate limits on accounts and inboxes, a database dump before every deploy - Sign-up, login, the invitation flows and password recovery allow ten requests a minute per client address; the inboxes give each sending origin (the keyId's) a bucket of 300 that refills at 300 a minute, and answer 429 beyond it, which peers retry. - deploy.yml dumps the PrivaPub database to /var/backups before it stops the service (the last seven are kept), and after the swap checks that Swagger answers 404 and that the shared inbox answers junk with 400 and an unsigned activity with 401. - ActivityPubClient and PostBoost, never used, are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- .gitea/workflows/deploy.yml | 16 ++++++++ .../ClientToServer/RootUserController.cs | 16 ++++---- .../Controllers/PeasantsController.cs | 8 ++-- PrivaPub/Infrastructure/RateLimiting.cs | 39 +++++++++++++++++++ PrivaPub/Models/Post/PostBoost.cs | 12 ------ PrivaPub/Program.cs | 3 ++ PrivaPub/Services/ActivityPubClient.cs | 12 ------ 7 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 PrivaPub/Infrastructure/RateLimiting.cs delete mode 100644 PrivaPub/Models/Post/PostBoost.cs delete mode 100644 PrivaPub/Services/ActivityPubClient.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1f269b3..90d7890 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -12,6 +12,8 @@ env: BACKUPS: /var/backups/privapub.thepra.dev LOCAL_URL: http://127.0.0.1:6970 PUBLIC_URL: https://privapub.thepra.dev + MONGO_URI: mongodb://127.0.0.1:27022 + MONGO_DB: PrivaPub jobs: site: @@ -55,6 +57,13 @@ jobs: echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV" ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true + - name: Dump the database + run: | + DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz" + mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --gzip --archive="$DUMP" + echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))" + ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true + - name: Stop, sync, start run: | sudo systemctl stop "$UNIT" @@ -88,4 +97,11 @@ jobs: [ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo") [ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html") + [ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede") + [ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; } + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \ + --data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede") + [ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; } echo "::notice::serving $served" diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index 496eef2..f990f97 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; @@ -11,6 +12,7 @@ using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User.Avatar; using PrivaPub.Extensions; using PrivaPub.Federation.Actors; +using PrivaPub.Infrastructure; using PrivaPub.Models; using PrivaPub.Models.User; using PrivaPub.Resources; @@ -56,7 +58,7 @@ namespace PrivaPub.Controllers.ClientToServer #region User endpoints - [HttpPost, Route("/clientapi/user/signup"), Authorize(Policy = Policies.IsUser), AllowAnonymous] + [HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous] public async Task SignUp(LoginForm signUpForm) { if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); @@ -80,7 +82,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/login"), Authorize(Policy = Policies.IsUser), AllowAnonymous] + [HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous] public async Task Login(LoginForm loginForm) { if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions"); @@ -106,7 +108,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/invitation/signup"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token) { var result = new WebResult(); @@ -156,7 +158,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/invitation/login"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task InvitationLogin(InvitationLoginForm loginForm, CancellationToken token) { var result = new WebResult(); @@ -339,7 +341,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/recover/password"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task RecoverPassword(PasswordRecoveryForm passwordRecoveryForm) { var result = new WebResult(); @@ -363,7 +365,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/recover/valid"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task IsValidRecoveryCode( [FromBody, Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), @@ -386,7 +388,7 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Route("/clientapi/user/recover/update/password"), AllowAnonymous] + [HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task ChangePassword(NewPasswordForm newPasswordForm) { var result = new WebResult(); diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index c23f67a..a46708c 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using MongoDB.Entities; @@ -11,6 +12,7 @@ using PostEntity = PrivaPub.Models.Post.Post; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Inbox; +using PrivaPub.Infrastructure; namespace PrivaPub.Federation.Controllers { @@ -113,7 +115,7 @@ namespace PrivaPub.Federation.Controllers return Activity(note); } - [HttpPost, Route("{actor}/mouth")] + [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)] public async Task Inbox(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); @@ -122,11 +124,11 @@ namespace PrivaPub.Federation.Controllers return Answer(await _inbox.Receive(Request, local, token)); } - [HttpPost, Route("{actor}/human-centipede")] + [HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)] public async Task ActorSharedInbox(string actor, CancellationToken token) => Answer(await _inbox.Receive(Request, default, token)); - [HttpPost, Route("/human-centipede")] + [HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)] public async Task SharedInbox(CancellationToken token) => Answer(await _inbox.Receive(Request, default, token)); diff --git a/PrivaPub/Infrastructure/RateLimiting.cs b/PrivaPub/Infrastructure/RateLimiting.cs new file mode 100644 index 0000000..b81476f --- /dev/null +++ b/PrivaPub/Infrastructure/RateLimiting.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.RateLimiting; + +using PrivaPub.Federation.Objects; +using PrivaPub.Federation.Signing; + +using System.Threading.RateLimiting; + +namespace PrivaPub.Infrastructure +{ + public static class RateLimiting + { + public const string Accounts = "accounts"; + public const string Inbox = "inbox"; + + public static IServiceCollection PrivaPubRateLimiting(this IServiceCollection service) => + service.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); + options.AddPolicy(Inbox, context => RateLimitPartition.GetTokenBucketLimiter( + SenderOrigin(context.Request) ?? "unsigned:" + context.Connection.RemoteIpAddress, + _ => new TokenBucketRateLimiterOptions + { + TokenLimit = 300, + TokensPerPeriod = 50, + ReplenishmentPeriod = TimeSpan.FromSeconds(10), + QueueLimit = 0 + })); + }); + + static string SenderOrigin(HttpRequest request) + { + var signature = request.Headers["Signature"].ToString(); + return string.IsNullOrEmpty(signature) ? default : Origin.Of(HttpSignatures.Parse(signature)?.KeyId); + } + } +} diff --git a/PrivaPub/Models/Post/PostBoost.cs b/PrivaPub/Models/Post/PostBoost.cs deleted file mode 100644 index 8865876..0000000 --- a/PrivaPub/Models/Post/PostBoost.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PrivaPub.Models.Post -{ - public class PostBoost - { - public string PostId { get; set; } - public string GroupUserId { get; set; } - - public bool IsFederatedCopy { get; set; } - - public DateTime CreationDate { get; set; } - } -} diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 83aef11..3d8e646 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -11,6 +11,7 @@ using Serilog; using PrivaPub.Data; using PrivaPub.Extensions; +using PrivaPub.Infrastructure; using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Data; using PrivaPub.Infrastructure.Http; @@ -52,6 +53,7 @@ try .PrivaPubServicesConfiguration() .PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubCORSConfiguration() + .PrivaPubRateLimiting() .PrivaPubMiddlewareConfiguration(); } catch (Exception ex) @@ -122,6 +124,7 @@ try app.UseRequestLocalization(await localizationService.Get()); app.UseRouting(); + app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/PrivaPub/Services/ActivityPubClient.cs b/PrivaPub/Services/ActivityPubClient.cs deleted file mode 100644 index 393277b..0000000 --- a/PrivaPub/Services/ActivityPubClient.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace PrivaPub.Services -{ - public class ActivityPubClient : HttpClient - { - //readonly HttpClient client; - - //public ActivityPubClient(HttpClient client) - //{ - // this.client = client; - //} - } -}