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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:02:21 +02:00
1 parent d2f0c7a14e
commit d80cd42a0c
7 files changed
+72 -34

No files matched your search

+16
View File
@@ -12,6 +12,8 @@ env:
BACKUPS: /var/backups/privapub.thepra.dev BACKUPS: /var/backups/privapub.thepra.dev
LOCAL_URL: http://127.0.0.1:6970 LOCAL_URL: http://127.0.0.1:6970
PUBLIC_URL: https://privapub.thepra.dev PUBLIC_URL: https://privapub.thepra.dev
MONGO_URI: mongodb://127.0.0.1:27022
MONGO_DB: PrivaPub
jobs: jobs:
site: site:
@@ -55,6 +57,13 @@ jobs:
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV" echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true 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 - name: Stop, sync, start
run: | run: |
sudo systemctl stop "$UNIT" sudo systemctl stop "$UNIT"
@@ -88,4 +97,11 @@ jobs:
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; } [ "$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=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; } [ "$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" echo "::notice::serving $served"
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -11,6 +12,7 @@ using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar; using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure;
using PrivaPub.Models; using PrivaPub.Models;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.Resources; using PrivaPub.Resources;
@@ -56,7 +58,7 @@ namespace PrivaPub.Controllers.ClientToServer
#region User endpoints #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<IActionResult> SignUp(LoginForm signUpForm) public async Task<IActionResult> SignUp(LoginForm signUpForm)
{ {
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); 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<IActionResult> Login(LoginForm loginForm) public async Task<IActionResult> Login(LoginForm loginForm)
{ {
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions"); 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<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token) public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
{ {
var result = new WebResult(); 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<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token) public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
{ {
var result = new WebResult(); 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<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm) public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm)
{ {
var result = new WebResult(); 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<IActionResult> IsValidRecoveryCode( public async Task<IActionResult> IsValidRecoveryCode(
[FromBody, [FromBody,
Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), 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<IActionResult> ChangePassword(NewPasswordForm newPasswordForm) public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
{ {
var result = new WebResult(); var result = new WebResult();
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using MongoDB.Entities; using MongoDB.Entities;
@@ -11,6 +12,7 @@ using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox;
using PrivaPub.Infrastructure;
namespace PrivaPub.Federation.Controllers namespace PrivaPub.Federation.Controllers
{ {
@@ -113,7 +115,7 @@ namespace PrivaPub.Federation.Controllers
return Activity(note); return Activity(note);
} }
[HttpPost, Route("{actor}/mouth")] [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> Inbox(string actor, CancellationToken token) public async Task<IActionResult> Inbox(string actor, CancellationToken token)
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
@@ -122,11 +124,11 @@ namespace PrivaPub.Federation.Controllers
return Answer(await _inbox.Receive(Request, local, token)); return Answer(await _inbox.Receive(Request, local, token));
} }
[HttpPost, Route("{actor}/human-centipede")] [HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) => public async Task<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token)); Answer(await _inbox.Receive(Request, default, token));
[HttpPost, Route("/human-centipede")] [HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
public async Task<IActionResult> SharedInbox(CancellationToken token) => public async Task<IActionResult> SharedInbox(CancellationToken token) =>
Answer(await _inbox.Receive(Request, default, token)); Answer(await _inbox.Receive(Request, default, token));
+39
View File
@@ -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);
}
}
}
-12
View File
@@ -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; }
}
}
+3
View File
@@ -11,6 +11,7 @@ using Serilog;
using PrivaPub.Data; using PrivaPub.Data;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Cli; using PrivaPub.Infrastructure.Cli;
using PrivaPub.Infrastructure.Data; using PrivaPub.Infrastructure.Data;
using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Http;
@@ -52,6 +53,7 @@ try
.PrivaPubServicesConfiguration() .PrivaPubServicesConfiguration()
.PrivaPubFederationConfiguration(builder.Configuration) .PrivaPubFederationConfiguration(builder.Configuration)
.PrivaPubCORSConfiguration() .PrivaPubCORSConfiguration()
.PrivaPubRateLimiting()
.PrivaPubMiddlewareConfiguration(); .PrivaPubMiddlewareConfiguration();
} }
catch (Exception ex) catch (Exception ex)
@@ -122,6 +124,7 @@ try
app.UseRequestLocalization(await localizationService.Get()); app.UseRequestLocalization(await localizationService.Get());
app.UseRouting(); app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
-12
View File
@@ -1,12 +0,0 @@
namespace PrivaPub.Services
{
public class ActivityPubClient : HttpClient
{
//readonly HttpClient client;
//public ActivityPubClient(HttpClient client)
//{
// this.client = client;
//}
}
}