fix(server): forget the IP addresses of sign-up attempts
Serverpod's rate limit for new anonymous accounts (30 per IP address and hour) notes every attempt with the address it came from, and deletes those notes only for email sign-in, which this instance doesn't offer. So the IP address of everyone who ever started the app stayed in the database, and in every backup, for good. The server now deletes them hourly once the limit's hour is past, so an address is kept for at most two hours. The rows already there go at the first run after the deploy; backups keep them until they expire (about three months). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ab4542f143
commit
0d03ef7973
7 changed files with 131 additions and 9 deletions
|
|
@ -34,6 +34,15 @@ Future<void> run(List<String> args) async {
|
|||
.pruning
|
||||
.pruneAnonymousPeople();
|
||||
|
||||
// The IP addresses of sign-up attempts, hourly, once the rate limit is
|
||||
// done with them (decided 2026-10-10).
|
||||
await pod.futureCalls.cancel(_attemptPruning);
|
||||
await pod.futureCalls
|
||||
.callRecurring(identifier: _attemptPruning)
|
||||
.cron('27 * * * *')
|
||||
.pruning
|
||||
.forgetSignUpAttempts();
|
||||
|
||||
// So are the files nobody can reach any more (ADR 0004, rule 5).
|
||||
await pod.futureCalls.cancel(_filePruning);
|
||||
await pod.futureCalls
|
||||
|
|
@ -79,6 +88,9 @@ Future<void> _loadDictionaries(Serverpod pod) async {
|
|||
/// The identifier of the daily pruning.
|
||||
const _pruning = 'prune-abandoned-anonymous-people';
|
||||
|
||||
/// The identifier of the hourly forgetting of sign-up attempts.
|
||||
const _attemptPruning = 'forget-sign-up-attempts';
|
||||
|
||||
/// The identifier of the daily pruning of files.
|
||||
const _filePruning = 'prune-unreferenced-files';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
|
|
@ -131,3 +132,22 @@ Future<int> pruneAbandonedAnonymousPeople(
|
|||
}
|
||||
return abandoned.length;
|
||||
}
|
||||
|
||||
/// Deletes Serverpod's notes of sign-up attempts that are older than the
|
||||
/// rate limit looks back ([anonymousSignUpLimit]) at [now], and returns how
|
||||
/// many: each holds the IP address it came from, which the instance keeps no
|
||||
/// longer than the limit needs it (decided 2026-10-10). Serverpod itself
|
||||
/// deletes them only for email sign-in, which this instance doesn't offer.
|
||||
Future<int> forgetOldSignUpAttempts(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
}) async {
|
||||
final cutoff = now.toUtc().subtract(anonymousSignUpLimit.timeframe);
|
||||
// DELETE FROM "serverpod_auth_idp_rate_limited_request_attempt"
|
||||
// WHERE "attemptedAt" < $1
|
||||
final forgotten = await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.attemptedAt < cutoff,
|
||||
);
|
||||
return forgotten.length;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ final List<TokenManagerBuilder> tokenManagerBuilders = [
|
|||
),
|
||||
];
|
||||
|
||||
/// How many new anonymous accounts one IP address may make, and in what
|
||||
/// time. Serverpod notes each attempt with the address it came from, which
|
||||
/// the instance forgets once the time is up (`forgetOldSignUpAttempts`).
|
||||
const anonymousSignUpLimit = RateLimit(
|
||||
maxAttempts: 30,
|
||||
timeframe: Duration(hours: 1),
|
||||
);
|
||||
|
||||
/// The ways people sign in on [pod].
|
||||
///
|
||||
/// Anonymous accounts are the trial mode for solo use. At most 30 new ones per
|
||||
|
|
@ -26,11 +34,6 @@ final List<TokenManagerBuilder> tokenManagerBuilders = [
|
|||
/// [gitHubSignInAvailable]); its helpers serve our own slim login
|
||||
/// (`github.dart`), and Serverpod's stock GitHub login endpoint isn't exposed.
|
||||
List<IdentityProviderBuilder> identityProviderBuilders(Serverpod pod) => [
|
||||
const AnonymousIdpConfig(
|
||||
perIpAddressRateLimit: RateLimit(
|
||||
maxAttempts: 30,
|
||||
timeframe: Duration(hours: 1),
|
||||
),
|
||||
),
|
||||
const AnonymousIdpConfig(perIpAddressRateLimit: anonymousSignUpLimit),
|
||||
if (gitHubSignInAvailable(pod)) GitHubIdpConfigFromPasswords(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import 'package:clock/clock.dart';
|
|||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Deletes the anonymous people nobody can return to; the server schedules it
|
||||
/// daily when it starts (`server.dart`).
|
||||
/// Deletes the anonymous people nobody can return to, daily, and the IP
|
||||
/// addresses of sign-up attempts the rate limit is done with, hourly; the
|
||||
/// server schedules both when it starts (`server.dart`).
|
||||
class PruningFutureCall extends FutureCall {
|
||||
/// Prunes one batch and logs how many people went.
|
||||
Future<void> pruneAnonymousPeople(Session session) async {
|
||||
|
|
@ -13,4 +14,11 @@ class PruningFutureCall extends FutureCall {
|
|||
);
|
||||
session.log('Pruned $pruned abandoned anonymous people.');
|
||||
}
|
||||
|
||||
/// Forgets the sign-up attempts the rate limit is done with, with their IP
|
||||
/// addresses, and logs how many.
|
||||
Future<void> forgetSignUpAttempts(Session session) async {
|
||||
final forgotten = await forgetOldSignUpAttempts(session, now: clock.now());
|
||||
session.log('Forgot $forgotten sign-up attempts.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ class FutureCalls extends _is.FutureCallDispatch<_FutureCallRef> {
|
|||
'FilePruningPruneFilesFutureCall': FilePruningPruneFilesFutureCall(),
|
||||
'PruningPruneAnonymousPeopleFutureCall':
|
||||
PruningPruneAnonymousPeopleFutureCall(),
|
||||
'PruningForgetSignUpAttemptsFutureCall':
|
||||
PruningForgetSignUpAttemptsFutureCall(),
|
||||
'DictionaryReloadReloadDictionariesFutureCall':
|
||||
DictionaryReloadReloadDictionariesFutureCall(),
|
||||
'LearnerCountCountLearnersFutureCall':
|
||||
|
|
@ -225,6 +227,13 @@ class _PruningFutureCallDispatcher {
|
|||
null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> forgetSignUpAttempts() {
|
||||
return _invokeFutureCall(
|
||||
'PruningForgetSignUpAttemptsFutureCall',
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DictionaryReloadFutureCallDispatcher {
|
||||
|
|
@ -277,6 +286,19 @@ class PruningPruneAnonymousPeopleFutureCall extends _is.FutureCall
|
|||
}
|
||||
}
|
||||
|
||||
/// Forgets the sign-up attempts the rate limit is done with, with their IP
|
||||
/// addresses, and logs how many.
|
||||
class PruningForgetSignUpAttemptsFutureCall extends _is.FutureCall
|
||||
implements _is.InvokableFutureCall {
|
||||
@override
|
||||
_ida.Future<void> invoke(
|
||||
_is.Session session,
|
||||
_is.SerializableModel? object,
|
||||
) async {
|
||||
await _igevg04y.PruningFutureCall().forgetSignUpAttempts(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the dictionary files that changed since they were loaded.
|
||||
class DictionaryReloadReloadDictionariesFutureCall extends _is.FutureCall
|
||||
implements _is.InvokableFutureCall {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,48 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
withServerpod('Forgetting sign-up attempts', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
|
||||
setUp(() => session = sessionBuilder.build());
|
||||
|
||||
test('deletes those the rate limit is done with, with their IP '
|
||||
'addresses, and keeps the rest', () async {
|
||||
Future<UuidValue> attempt(Duration ago) async =>
|
||||
(await RateLimitedRequestAttempt.db.insertRow(
|
||||
session,
|
||||
RateLimitedRequestAttempt(
|
||||
domain: 'anonymous',
|
||||
source: 'account_creation',
|
||||
key: '203.0.113.7',
|
||||
ipAddress: '203.0.113.7',
|
||||
attemptedAt: now.subtract(ago),
|
||||
),
|
||||
)).id!;
|
||||
final old = await attempt(const Duration(days: 3));
|
||||
final done = await attempt(const Duration(minutes: 61));
|
||||
final recent = await attempt(const Duration(minutes: 59));
|
||||
|
||||
// Others may have been left by earlier runs.
|
||||
expect(
|
||||
await forgetOldSignUpAttempts(session, now: now),
|
||||
greaterThanOrEqualTo(2),
|
||||
);
|
||||
|
||||
for (final forgotten in [old, done]) {
|
||||
expect(
|
||||
await RateLimitedRequestAttempt.db.findById(session, forgotten),
|
||||
isNull,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await RateLimitedRequestAttempt.db.findById(session, recent),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
|
|
|
|||
|
|
@ -1700,6 +1700,21 @@ class _PruningFutureCall {
|
|||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> forgetSignUpAttempts(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||
try {
|
||||
await _iip9210p.PruningForgetSignUpAttemptsFutureCall().invoke(
|
||||
_localUniqueSession,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DictionaryReloadFutureCall {
|
||||
|
|
|
|||
Loading…
Reference in a new issue