fix(auth): count per-network limits by the visitor behind Caddy

Every per-network limit counted by the connection's address, which behind
Caddy is Caddy's for everyone: new anonymous accounts, codes by mail and
password sign-ins were each limited for the whole instance.

`networkOf` (now `common/network.dart`) takes the last address of
X-Forwarded-For, the one the proxy saw itself, for a connection from a
proxy the operator trusts (`TRUSTED_PROXIES`, which deploy/compose.yaml
sets to Caddy's fixed address); otherwise, or where that is no address,
the connection's. Never the Forwarded header, which Caddy passes on as
sent, so never Serverpod's `remoteInfo`. A value that isn't addresses
stops the server as it starts.

Serverpod's own limit on anonymous accounts counts by the connection's
address too and can't be told otherwise, so it is off; the anonymous
endpoint counts them itself, 30 an hour per network as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 14:09:27 +02:00
commit d9039d36ef
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 326 additions and 28 deletions

View file

@ -14,6 +14,7 @@ export 'src/auth/github.dart';
export 'src/auth/github_sign_in_endpoint.dart' show gitHubAccountToSignIn;
export 'src/auth/identified.dart';
export 'src/auth/scopes.dart';
export 'src/common/network.dart';
export 'src/common/short_code.dart';
export 'src/generated/protocol.dart' hide Protocol;
export 'src/halkyon_base.dart';

View file

@ -1,5 +1,35 @@
import 'package:halkyon_base_server/src/auth/auth_config.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
/// Signs people in anonymously; the app calls it on first launch. Creating an
/// account is rate-limited per IP address (see `auth_config.dart`).
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {}
/// Signs people in anonymously; the app calls it on first launch.
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {
/// Creates a new anonymous account and returns its session: at most 30 an
/// hour from one network (`anonymousSignUpLimit`), counted by the
/// visitor's address (`networkOf`) rather than as Serverpod's own limit
/// counts them, by the connection's, which behind a proxy is the proxy's
/// for everyone.
@override
Future<AuthSuccess> login(Session session, {String? token}) async {
if (!await _accountsPerNetwork.tryRecordAttempt(
session,
key: networkOf(session),
)) {
throw AnonymousAccountBlockedException(
reason: AnonymousAccountBlockedExceptionReason.tooManyAttempts,
);
}
return await super.login(session, token: token);
}
}
final _accountsPerNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'anonymous',
source: 'accounts_per_network',
maxAttempts: anonymousSignUpLimit.maxAttempts,
timeframe: anonymousSignUpLimit.timeframe,
),
);

View file

@ -16,9 +16,12 @@ 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`).
/// How many new anonymous accounts one network (the visitor's IP address,
/// `networkOf`) may make, and in what time: counted by the anonymous
/// endpoint itself (`AnonymousIdpEndpoint`), as Serverpod's own limit counts
/// by the connection's address, behind a proxy the proxy's. Each attempt is
/// noted with that address, which the instance forgets once the time is up
/// (`forgetOldSignUpAttempts`).
const anonymousSignUpLimit = RateLimit(
maxAttempts: 30,
timeframe: Duration(hours: 1),
@ -29,11 +32,13 @@ const anonymousSignUpLimit = RateLimit(
/// Anonymous accounts are the trial mode for solo use. At most 30 new ones per
/// IP address and hour (Serverpod's default is 100): a school class behind one
/// address can still start together, while one abuser gets at most 720 a day.
/// [anonymousSignUpLimit] counts them; Serverpod's own limit is off, as it
/// would count every visitor behind a proxy as one.
///
/// GitHub only where the operator registered a GitHub OAuth app (see
/// [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: anonymousSignUpLimit),
const AnonymousIdpConfig(perIpAddressRateLimit: null),
if (gitHubSignInAvailable(pod)) GitHubIdpConfigFromPasswords(),
];

View file

@ -4,6 +4,7 @@ import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/src/auth/code_mail.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/common/short_code.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
@ -97,7 +98,8 @@ final _codesPerAddress = DatabaseRateLimiter(
),
);
/// How many codes one network (IP address) asks for in an hour.
/// How many codes one network (the visitor's IP address, `networkOf`) asks
/// for in an hour.
final _codesPerNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
@ -134,7 +136,6 @@ Future<UuidValue> sendSignInCode(
if (!await _codesPerAddress.tryRecordAttempt(session, key: hash) ||
!await _codesPerNetwork.tryRecordAttempt(
session,
// As Serverpod's own rate limits take it (no request in tests).
key: network ?? networkOf(session),
)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.tooManyCodes);
@ -365,11 +366,6 @@ Future<UuidValue> completeEmailSwitch(
});
}
/// The network [session]'s request came from, as Serverpod's own rate limits
/// take it (no request in tests).
String networkOf(Session session) =>
'${session.request?.connectionInfo.remote.address}';
/// Deletes the code requests past their time at [now], and returns how many:
/// hourly, with the rate limit's notes (`PruningFutureCall`).
Future<int> forgetExpiredEmailCodes(

View file

@ -1,5 +1,6 @@
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';

View file

@ -0,0 +1,90 @@
import 'dart:io';
import 'package:serverpod/serverpod.dart';
// The network a request came from, which the per-network rate limits count
// by (new anonymous accounts, codes by mail, password sign-ins and proofs of
// the recovery phrase). Behind a reverse proxy every connection comes from
// the proxy, which would make each of those limits the whole instance's.
// So for a connection from a proxy the operator trusts (`TRUSTED_PROXIES`;
// deploy/compose.yaml names Caddy), the visitor is the last address of
// `X-Forwarded-For`: the one the proxy saw itself and added (Caddy replaces
// what a visitor sends there unless that visitor is a proxy it trusts).
// Never the `Forwarded` header, which Caddy passes on as the visitor wrote
// it, and so never Serverpod's `remoteInfo`, which prefers that header.
/// The network [session]'s request came from (see above): its visitor's
/// address, or `null` for a session without a request (tests, future
/// calls), as the limits have always taken it.
String networkOf(Session session) {
final request = session.request;
return '${visitorAddress(
socket: request == null ? null : '${request.connectionInfo.remote.address}',
forwardedFor: request?.headers['x-forwarded-for'],
trustedProxies: trustedProxies(),
)}';
}
/// The address of the visitor whose connection came from [socket] (none
/// without one), with the `X-Forwarded-For` header lines [forwardedFor]: for
/// a connection from one of [trustedProxies], the header's last address;
/// otherwise, or where that is no address, [socket]'s own. An IPv4 address
/// written as IPv6 (`::ffff:192.0.2.1`, as the server's IPv6 socket sees an
/// IPv4 connection) is the IPv4 address.
String? visitorAddress({
required String? socket,
required Iterable<String>? forwardedFor,
required Set<String> trustedProxies,
}) {
if (socket == null) {
return null;
}
final connection = _addressOf(socket) ?? socket;
if (!trustedProxies.contains(connection)) {
return connection;
}
final last = forwardedFor?.expand((line) => line.split(',')).lastOrNull;
return (last == null ? null : _addressOf(last)) ?? connection;
}
/// The proxies this server takes `X-Forwarded-For` from: the operator's
/// `TRUSTED_PROXIES`, read once. Throws a [StateError] for a value that
/// isn't addresses, which hosts check as they start (`addInstanceRoutes`).
Set<String> trustedProxies() => _trustedProxies;
final _trustedProxies = trustedProxiesFrom(
Platform.environment['TRUSTED_PROXIES'],
);
/// [value] (`TRUSTED_PROXIES`) as the addresses of the proxies trusted:
/// IP addresses separated by commas or spaces; none where unset or empty (as
/// Compose passes an unset variable). Throws a [StateError] for anything
/// else, such as a name or a network: the proxy's own address is what its
/// connections come from.
Set<String> trustedProxiesFrom(String? value) => {
for (final entry in (value ?? '').split(RegExp(r'[\s,]+')))
if (entry.isNotEmpty)
_addressOf(entry) ??
(throw StateError(
'TRUSTED_PROXIES holds "$entry": it must be IP addresses, such '
'as 172.30.80.2.',
)),
};
/// [text] as an IP address in its usual form, an IPv4 address written as
/// IPv6 as the IPv4 address; null for anything else.
String? _addressOf(String text) {
final address = InternetAddress.tryParse(text.trim());
if (address == null) {
return null;
}
final bytes = address.rawAddress;
final mapped =
address.type == InternetAddressType.IPv6 &&
bytes.take(10).every((byte) => byte == 0) &&
bytes[10] == 0xff &&
bytes[11] == 0xff;
return mapped
? InternetAddress.fromRawAddress(bytes.sublist(12)).address
: address.address;
}

View file

@ -1,6 +1,6 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';

View file

@ -1,6 +1,6 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:halkyon_base_server/src/auth/github.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
@ -16,8 +17,8 @@ import 'package:serverpod/serverpod.dart';
/// ([accountGonePath]); each answers only in its mode, and only with the
/// instance's secret. Caddy never forwards them: the services reach each
/// other on the instance's own network. Throws as [checkInstanceSecret],
/// [checkPasswordKeys] and [gitHubSignInEnds] do, so a server set up so
/// stops as it starts.
/// [checkPasswordKeys], [gitHubSignInEnds] and [trustedProxies] do, so a
/// server set up so stops as it starts.
void addInstanceRoutes(Serverpod pod) {
checkInstanceSecret(pod);
// Here too, as every host calls this as it starts: a key for password
@ -25,6 +26,9 @@ void addInstanceRoutes(Serverpod pod) {
// stops the server rather than serving with it (ADR 0017).
checkPasswordKeys(pod);
gitHubSignInEnds(pod);
// And proxies that are no addresses: counted by the proxy's address, every
// visitor would share each per-network limit.
trustedProxies();
if (pod.config.webServer == null) {
return;
}