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:
parent
c67b492bfd
commit
d9039d36ef
16 changed files with 326 additions and 28 deletions
|
|
@ -115,7 +115,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
|
||||
- **Discussions** (ADR 0013, decided 2026-10-15): questions on a published studyset, a topic in it or one of its cards, notes or steps, answered by anyone and commented on (under the question and each answer, never under a comment), as Stack Exchange has them, without votes: the asker accepts an answer, which comes first, the rest oldest first. Unlike feedback they are server tables (`discussion_post`, `discussion_seen`, `lib/src/discussion/`), written through endpoints, online only, so posts can be edited and ordered and the synced tables stay as they are: `DiscussionsEndpoint` (`requireLogin`) lists (by topic, item or title, 30 to a page, last active first) and reads; `DiscussionEndpoint` (`identified`, and a public profile) asks, answers, comments, edits, deletes, accepts, hides (the studyset's author), and keeps the inbox. A question names its target as the release does, and the instance keeps the topics it is within from git's file list (`ReleaseRepository.paths`, `topicsOf`), so a topic lists what is asked within it and below; one about the studyset as a whole names no item. Questions and answers are rich text without files (the composer's Insert menu has no media, and content holding images or sounds is refused: the person's files are theirs alone), comments plain text of up to 600 characters, 30 posts an hour. The author hides posts and blocks people with feedback's own blocks; a withdrawn studyset takes no new questions. A post with others' replies under it is never deleted, only emptied ("Deleted question"), with its writer's account too (`deleteDiscussionsOf` in `deletePerson`). In the app (`feature/discussion/`): a copy's menu opens the discussions within that studyset or topic (`RoutePaths.discussions`, over the tabs), a studied card from a release offers Ask about this, Explore's summary and the Publish sheet open the whole studyset's, and the Profile tab's inbox lists the discussions the person follows, counted on the tab with the feedback (`InboxCountBloc`).
|
||||
- **Deleting a person** goes through halkyon_base's `deletePerson`, in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then Learn's own data of theirs (`deleteLearnData`, `lib/src/auth/account.dart`, the hook Learn configures: their space with all its content through the foreign keys and its stored files, discussions, published studysets, blocks, what they read, the assistants they connected, their file allowance; their releases' repositories once the deletion holds), then the module's (their public profile with its avatar), then the person with their identities; an identified person's deletion also tells the instance's members (ADR 0016), the notices kept in the same transaction. What they wrote in discussions goes too, emptied where others replied under it (ADR 0013). A new table holding a person's data gets a line in that hook. The module's `account.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
|
||||
- **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes Serverpod's rate limit makes of anonymous sign-ups (`serverpod_auth_idp_rate_limited_request_attempt`, each with the IP address it came from) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that; hourly too, the deletions the instance's members haven't confirmed yet (`tellMembersOfDeletions`, ADR 0016). Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
|
||||
- **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes anonymous people unused for 90 days, daily, scheduled when the server starts; and hourly the notes the rate limits make (`serverpod_auth_idp_rate_limited_request_attempt`; anonymous sign-ups, codes by mail and password sign-ins are counted per network, each note keyed by the visitor's IP address: `networkOf`, which behind Caddy reads the last address of `X-Forwarded-For` from the proxies `TRUSTED_PROXIES` names, as `deploy/compose.yaml` names Caddy, never the `Forwarded` header) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that; hourly too, the deletions the instance's members haven't confirmed yet (`tellMembersOfDeletions`, ADR 0016). Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
|
||||
- **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed.
|
||||
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.
|
||||
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@
|
|||
}
|
||||
|
||||
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
|
||||
# Caddy puts the visitor's address last in X-Forwarded-For (what a visitor sends
|
||||
# there it replaces), which the server's per-network limits count by, as
|
||||
# TRUSTED_PROXIES names Caddy (compose.yaml); the server never reads the Forwarded
|
||||
# header, which Caddy passes on as sent.
|
||||
handle {
|
||||
reverse_proxy server:8080
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ services:
|
|||
networks:
|
||||
edge:
|
||||
# Fixed, so containers behind Caddy can trust it as their proxy (the
|
||||
# visitor's own address comes in X-Forwarded-For).
|
||||
# visitor's own address comes in X-Forwarded-For): the server's
|
||||
# TRUSTED_PROXIES, Jellyfin's known proxies.
|
||||
ipv4_address: 172.30.80.2
|
||||
depends_on:
|
||||
- server
|
||||
|
|
@ -80,6 +81,10 @@ services:
|
|||
SERVERPOD_PASSWORD_totpKey: ${TOTP_KEY:-}
|
||||
SERVERPOD_PASSWORD_recoveryCodeKey: ${RECOVERY_CODE_KEY:-}
|
||||
SERVERPOD_PASSWORD_signInDecoyKey: ${SIGN_IN_DECOY_KEY:-}
|
||||
# Caddy, whose X-Forwarded-For names the visitor the per-network limits count
|
||||
# (new anonymous accounts, codes by mail, password sign-ins): without it, every
|
||||
# visitor would share each limit with everyone. Its address on `edge`, below.
|
||||
TRUSTED_PROXIES: 172.30.80.2
|
||||
# The day GitHub stops signing anyone in (`2027-01-22`); empty: no end.
|
||||
GITHUB_SIGN_IN_ENDS: ${GITHUB_SIGN_IN_ENDS:-}
|
||||
# Where the source of what this instance runs is; empty: the project's.
|
||||
|
|
|
|||
|
|
@ -74,8 +74,7 @@ class EndpointAccount extends _isc.EndpointRef {
|
|||
);
|
||||
}
|
||||
|
||||
/// Signs people in anonymously; the app calls it on first launch. Creating an
|
||||
/// account is rate-limited per IP address (see `auth_config.dart`).
|
||||
/// Signs people in anonymously; the app calls it on first launch.
|
||||
/// {@category Endpoint}
|
||||
class EndpointAnonymousIdp extends _iaic.EndpointAnonymousIdpBase {
|
||||
EndpointAnonymousIdp(_isc.EndpointCaller caller) : super(caller);
|
||||
|
|
@ -83,10 +82,11 @@ class EndpointAnonymousIdp extends _iaic.EndpointAnonymousIdpBase {
|
|||
@override
|
||||
String get name => 'halkyon_base.anonymousIdp';
|
||||
|
||||
/// Creates a new anonymous account and returns its session.
|
||||
///
|
||||
/// Invokes the [AnonymousIdp.beforeAnonymousAccount] callback if configured,
|
||||
/// which may prevent account creation if the endpoint is protected.
|
||||
/// 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
|
||||
_ida.Future<_iacc.AuthSuccess> login({String? token}) =>
|
||||
caller.callServerEndpoint<_iacc.AuthSuccess>(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
|
||||
| Endpoint | Methods | Who may call |
|
||||
|---|---|---|
|
||||
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per IP address and hour) | anyone |
|
||||
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
|
||||
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one | signed in |
|
||||
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code); a session | without a session |
|
||||
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes), `list`, `remove`, `newRecoveryCodes` | signed in |
|
||||
|
|
@ -41,6 +41,14 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
`https://<account domain>/@handle` (`ACCOUNT_DOMAIN`, else the server's public host),
|
||||
served by `addProfilePageRoute`, in English or German, its foot what the host's
|
||||
`profilePageNote` says.
|
||||
- **Per-network limits** (new anonymous accounts, codes by mail, password sign-ins,
|
||||
proofs of the recovery phrase) count by the visitor's IP address (`networkOf`): the
|
||||
connection's, or, for a connection from a proxy the operator trusts
|
||||
(`TRUSTED_PROXIES`), the last address of `X-Forwarded-For`, the one the proxy saw
|
||||
itself. Never the `Forwarded` header, which Caddy passes on as the visitor wrote it,
|
||||
so never Serverpod's `remoteInfo`. Serverpod's own limit on anonymous accounts is off
|
||||
(it counts by the connection's address, behind a proxy the proxy's for everyone); the
|
||||
endpoint counts them itself.
|
||||
- Refusals are `PasswordRefusedException`, `GitHubSignInRefusedException`,
|
||||
`EmailSignInRefusedException` and `ProfileRefusedException`, each with a reason;
|
||||
`ProfileNotFoundException` where there is no profile.
|
||||
|
|
@ -283,7 +291,11 @@ its app's). The environment: `ACCOUNT_REGISTER_URL` and `INSTANCE_MEMBERS` (abov
|
|||
one; `GITHUB_SIGN_IN_ENDS`, the day GitHub stops signing anyone in (`2027-01-22`);
|
||||
`SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`: where the instance keeps people's data
|
||||
and its backups, each `EU`, a country's code (`DE`) or a subdivision's (ISO 3166-2,
|
||||
`US-CA`, `DE-BY`), the backups also `none`; where the disks are, not which law governs.
|
||||
`US-CA`, `DE-BY`), the backups also `none`; where the disks are, not which law governs;
|
||||
`TRUSTED_PROXIES`, the addresses of the reverse proxies in front of the server (Caddy's
|
||||
on its compose network, which Halkyon Learn's `deploy/compose.yaml` sets), separated by
|
||||
commas or spaces: without, every visitor behind the proxy shares each per-network limit;
|
||||
anything but addresses stops the server (`addInstanceRoutes`).
|
||||
|
||||
## Developing it
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
90
packages/halkyon_base_server/lib/src/common/network.dart
Normal file
90
packages/halkyon_base_server/lib/src/common/network.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,20 @@ void main() {
|
|||
expect(authUser.id, authSuccess.authUserId);
|
||||
});
|
||||
|
||||
test("counts a new account once, by the visitor's network, not as "
|
||||
"Serverpod's own limit counts the connection's", () async {
|
||||
await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
|
||||
final notes = await RateLimitedRequestAttempt.db.find(
|
||||
sessionBuilder.build(),
|
||||
);
|
||||
expect(
|
||||
notes.map((note) => (note.domain, note.source, note.key)),
|
||||
// `networkOf`'s key for a test's session, which has no request.
|
||||
[('anonymous', 'accounts_per_network', 'null')],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses the 31st account from one IP address within an hour',
|
||||
() async {
|
||||
|
|
|
|||
136
packages/halkyon_base_server/test/unit/network_test.dart
Normal file
136
packages/halkyon_base_server/test/unit/network_test.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('The proxies the operator trusts (TRUSTED_PROXIES)', () {
|
||||
test('are none where unset or empty', () {
|
||||
expect(trustedProxiesFrom(null), isEmpty);
|
||||
expect(trustedProxiesFrom(''), isEmpty);
|
||||
expect(trustedProxiesFrom(' '), isEmpty);
|
||||
});
|
||||
|
||||
test('are IP addresses, separated by commas or spaces', () {
|
||||
expect(trustedProxiesFrom('172.30.80.2'), {'172.30.80.2'});
|
||||
expect(trustedProxiesFrom(' 172.30.80.2, 2001:db8::2 10.0.0.1 '), {
|
||||
'172.30.80.2',
|
||||
'2001:db8::2',
|
||||
'10.0.0.1',
|
||||
});
|
||||
});
|
||||
|
||||
test('take an IPv4 address written as IPv6 as the IPv4 address', () {
|
||||
expect(trustedProxiesFrom('::ffff:172.30.80.2'), {'172.30.80.2'});
|
||||
});
|
||||
|
||||
test('stop the server for anything but addresses', () {
|
||||
expect(() => trustedProxiesFrom('caddy'), throwsStateError);
|
||||
expect(() => trustedProxiesFrom('172.30.80.0/24'), throwsStateError);
|
||||
expect(
|
||||
() => trustedProxiesFrom('172.30.80.2, 300.1.1.1'),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group("A request's network", () {
|
||||
const caddy = {'172.30.80.2'};
|
||||
|
||||
test("is the connection's address where no proxy is trusted, whatever "
|
||||
'X-Forwarded-For says', () {
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '203.0.113.9',
|
||||
forwardedFor: ['198.51.100.7'],
|
||||
trustedProxies: const {},
|
||||
),
|
||||
'203.0.113.9',
|
||||
);
|
||||
});
|
||||
|
||||
test('is the last address of X-Forwarded-For, the one the trusted proxy '
|
||||
'saw itself, for a connection from that proxy', () {
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '172.30.80.2',
|
||||
forwardedFor: ['198.51.100.7'],
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
'198.51.100.7',
|
||||
);
|
||||
// What a visitor sent along is in front of it, never believed.
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '172.30.80.2',
|
||||
forwardedFor: ['10.9.9.9, 192.0.2.1 , 198.51.100.7'],
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
'198.51.100.7',
|
||||
);
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '172.30.80.2',
|
||||
forwardedFor: ['10.9.9.9', '2001:db8::7'],
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
'2001:db8::7',
|
||||
);
|
||||
});
|
||||
|
||||
test('is never X-Forwarded-For from a connection the operator trusts '
|
||||
'not', () {
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '203.0.113.9',
|
||||
forwardedFor: ['198.51.100.7'],
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
'203.0.113.9',
|
||||
);
|
||||
});
|
||||
|
||||
test("knows the proxy by its IPv4 address on the server's IPv6 socket", () {
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '::ffff:172.30.80.2',
|
||||
forwardedFor: ['::ffff:198.51.100.7'],
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
'198.51.100.7',
|
||||
);
|
||||
});
|
||||
|
||||
test("is the proxy's own address where its header is missing or no "
|
||||
'address', () {
|
||||
for (final header in [
|
||||
null,
|
||||
<String>[],
|
||||
[''],
|
||||
['198.51.100.7, '],
|
||||
['unknown'],
|
||||
['198.51.100.7:4711'],
|
||||
['[2001:db8::7]'],
|
||||
]) {
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: '172.30.80.2',
|
||||
forwardedFor: header,
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
'172.30.80.2',
|
||||
reason: '$header',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('is none without a connection (tests, future calls)', () {
|
||||
expect(
|
||||
visitorAddress(
|
||||
socket: null,
|
||||
forwardedFor: ['198.51.100.7'],
|
||||
trustedProxies: caddy,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue