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:
1 parent
d2f0c7a14e
commit
d80cd42a0c
7 files changed
+72
-34
No files matched your search
@@ -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"
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
|
||||
{
|
||||
var result = new WebResult();
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> SharedInbox(CancellationToken token) =>
|
||||
Answer(await _inbox.Receive(Request, default, token));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace PrivaPub.Services
|
||||
{
|
||||
public class ActivityPubClient : HttpClient
|
||||
{
|
||||
//readonly HttpClient client;
|
||||
|
||||
//public ActivityPubClient(HttpClient client)
|
||||
//{
|
||||
// this.client = client;
|
||||
//}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user