From f60a81e259efe019cb22a098952283a249a56482 Mon Sep 17 00:00:00 2001 From: Andrew Stowell Date: Tue, 2 Dec 2025 22:16:56 -0500 Subject: [PATCH] tentatively added rate limiting, block by IP if too many failed login attempts --- .editorconfig | 3 + .env.example | 13 ++++- Caddyfile | 27 +-------- Dockerfile | 13 ++++- health.php | 13 ++++- preauth-php.ini | 20 +++++++ src/Auth.php | 146 +++++++++++++++++++++++++++++++++++++++--------- template.php | 6 +- 8 files changed, 183 insertions(+), 58 deletions(-) create mode 100644 .editorconfig create mode 100644 preauth-php.ini diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a05ab45 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[Caddyfile] +indent_style = tab + diff --git a/.env.example b/.env.example index 6d071d0..daeaa3e 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,20 @@ PREAUTH_TITLE='Pre-Authentication System' PREAUTH_ID_NAME='Session ID' PREAUTH_TOKEN_NAME='Authentication Token' PREAUTH_SUBMIT_NAME='Submit' -PREAUTH_DENIED_CODE='418' +# how many consecutive failed login attempts before we block them (a remote-ip) +PREAUTH_RATE_LIMIT=4 +# maximum time between failed login attempts to still be consecutive (in minutes): 360 is 6 hours +PREAUTH_RATE_TIMEOUT=360 +# how long after last failed login will they be blocked (in minutes): 1440 is 24 hours +PREAUTH_RATE_BLOCKED=1440 +# what do we show when they get rate-limited +PREAUTH_DENIED_CODE=418 PREAUTH_DENIED_TITLE="I'm a teapot" PREAUTH_DENIED_MESSAGE='I refuse to brew coffee.' +# alternatively, you could use a more standard response +#PREAUTH_DENIED_CODE=429 +#PREAUTH_DENIED_TITLE='Too Many Requests' +#PREAUTH_DENIED_MESSAGE='Try again later.' # who owns the session files # permissions of the volume must match diff --git a/Caddyfile b/Caddyfile index ba8332e..32a6669 100644 --- a/Caddyfile +++ b/Caddyfile @@ -15,7 +15,7 @@ preauth.example.com { # snippet to put the pre-auth system in front any service easily (preauth) { reverse_proxy {args[0]} preauth:9000 { - method GET + method GET header_up X-Forwarded-Uri {uri} header_down -X-Powered-By rewrite /preauth.php @@ -33,7 +33,8 @@ preauth.example.com { } } -# snippet usage: this will cause pre-auth to restrict access to https://protected.example.com/secure +# snippet usage: this will cause preauth to restrict access to +# https://protected.example.com/secure protected.example.com { import preauth /secure { reverse_proxy protected-service:9000 @@ -41,25 +42,3 @@ protected.example.com { reverse_proxy exposed-service:9000 } -## alternatively, if using an older version of caddy, -## you will have to have add the boilerplate in your block -#protected.example.com { -# reverse_proxy /secure preauth:9000 { -# header_up X-Forwarded-Uri {uri} -# header_down -X-Powered-By -# rewrite /preauth.php -# transport fastcgi { -# root /preauth/ -# split .php -# } -# @preauth_ok status 2xx -# handle_response @preauth_ok { -# copy_response_headers { -# include Set-Cookie Location -# } -# reverse_proxy protected-service:9000 -# } -# } -# reverse_proxy exposed-service:9000 -#} - diff --git a/Dockerfile b/Dockerfile index caf68f3..336112a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,16 @@ RUN mkdir /preauth WORKDIR /preauth COPY . /preauth/ -# sessions are stored here +# add php config for rate limit monitoring +RUN mkdir -p /usr/local/etc/php/conf.d +COPY preauth-php.ini /usr/local/etc/php/conf.d/preauth-php.ini + +# login sessions are stored here RUN mkdir -p /tmp/sessions +# rate limit monitoring information is stored here +RUN mkdir -p /tmp/monitor + # fcgi command for the healthcheck RUN apk add fcgi @@ -19,9 +26,9 @@ RUN composer install EXPOSE 9000 -HEALTHCHECK --interval=60s --retries=3 --start-interval=1s --start-period=10s --timeout=5s \ +HEALTHCHECK --interval=5m --retries=3 --start-interval=5s --start-period=50s --timeout=5s \ CMD SCRIPT_NAME=/health.php SCRIPT_FILENAME=/preauth/health.php REQUEST_METHOD=GET \ cgi-fcgi -bind -connect localhost:9000 | grep 'online' || exit 1 -ENTRYPOINT /preauth/init.sh +ENTRYPOINT ["/preauth/init.sh"] diff --git a/health.php b/health.php index e6bc554..c72b4d4 100644 --- a/health.php +++ b/health.php @@ -1,4 +1,13 @@ key = base64_decode(getenv('PREAUTH_KEY') ?: ''); $this->token = getenv('PREAUTH_TOKEN') ?: ''; $this->expire = time() + 60 * ((int)getenv('PREAUTH_TTL') ?: 43200); - parse_str((parse_url(($_SERVER['HTTP_X_FORWARDED_URI'] ?? ''), PHP_URL_QUERY) ?: ''), $this->get); + parse_str((parse_url(( + $_SERVER['HTTP_X_FORWARDED_URI'] ?? '' + ), PHP_URL_QUERY) ?: ''), $this->get); } /** @@ -75,7 +83,7 @@ class Auth { * @return string returns the return-to-url, if one was specified, empty string otherwise */ public function getReturnTo(): string { - $rt = $this->get['rt'] ?? ''; + $rt = $this->get[self::RETURN_FIELD] ?? ''; if ( ! is_string($rt)) { $rt = ''; } @@ -103,6 +111,13 @@ class Auth { echo "ok $this->id"; return; } + + /* if too many requests (from the remote-ip), then trigger rate limiting */ + if ($this->rateLimit()) { + include __DIR__ . '/../400.php'; + exit(0); + } + /* if request is valid login attempt, (set cookie and) return to where they came from */ if ($this->login()) { if ($this->getReturnTo()) { @@ -114,12 +129,14 @@ class Auth { return; } - /* not already logged in, not trying to login, but on auth page, so present login screen */ + /* not already logged in, but on auth page, so present login screen */ + /* either not trying to login or had a failed login attempt */ if ($_SERVER['HTTP_HOST'] === "$this->preauth.$this->domain") { header('http/1.1 401 Unauthorized', true, 401); $this->stop = false; } else { - /* not already logged in, not trying to login, and not on auth page, so send to login screen */ + /* not already logged in, not trying to login, and not on auth page */ + /* so send to login screen */ $rt = rawurlencode( ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https') . '://' . ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $this->domain) . @@ -139,7 +156,9 @@ class Auth { $parts = array_reverse(explode('.', $host)); $keep = min(2, count($parts)); /* check if host should retain 3 parts, due to TLD */ - if (count($parts) > 2 && isset(self::TLD[$parts[0]]) && in_array($parts[1], self::TLD[$parts[0]], true)) { + if (count($parts) > 2 && isset(self::TLD[$parts[0]]) && + in_array($parts[1], self::TLD[$parts[0]], true) + ) { $keep = 3; } $parts = array_reverse(array_slice($parts, 0, $keep)); @@ -157,15 +176,18 @@ class Auth { } /** - * @return string|null returns the existing UUID if there is one and it is valid, null otherwise + * @return string|null returns the existing UUID if there is valid one, null otherwise */ private function getExistingUUID(): ?string { /* filter user input */ $encUUID = preg_replace(self::URL64, '', ($_COOKIE[self::NAME] ?? '')); - /* session exists as a file, in the format '$', where the filename is the encoded-uuid */ + /* session exists as a file where the filename is the encoded-uuid */ + /* in the format '$$$$' */ if ($encUUID && is_file(self::BASE . $encUUID)) { - [$iv, $id, $expire] = explode('$', (file_get_contents(self::BASE . $encUUID) ?: '') . '$$'); + [$iv, $id, $expire] = explode('$', (file_get_contents( + self::BASE . $encUUID + ) ?: '') . '$$'); if ($iv) { /* raw-uuid means not encoded, but still encrypted */ $rawUUID = base64_decode(strtr($encUUID, '+/', '-_')); @@ -175,13 +197,51 @@ class Auth { $this->id = substr(preg_replace(self::URL64, '', $id), 0, 100); return $uuid; } - /* we have something encrypted, but it is expired or not valid, so delete the session file */ + /* we have something encrypted, but it is expired or invalid, so delete it */ unlink(self::BASE . $encUUID); } } return null; } + /** + * Check if the remote-host has made too many login requests recently, and block if needed + * @return bool returns true if we should rate-limit this request, false otherwise + */ + private function rateLimit(): bool { + /* our identifier for this remote-host, replace all special characters with dashes */ + /* session_id() only allows ",", "-", and alphanumeric characters */ + $limiterId = preg_replace('[^a-zA-Z0-9]', '-', $this->getRemoteHost()); + session_id($limiterId); + session_start(); + + /* new remote-ip, start logging */ + if ( ! isset($_SESSION['count'], $_SESSION['time'])) { + $this->resetRateLimit(false); + return false; + } + + $sessionCount = (int)$_SESSION['count']; + $sessionTime = (int)$_SESSION['time']; + $rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4; + $rateTimeout = (int)getenv('PREAUTH_RATE_TIMEOUT') ?: 360; + $rateBlocked = (int)getenv('PREAUTH_RATE_BLOCKED') ?: 1440; + $rateMaximum = max($rateTimeout, $rateBlocked); + + if ($sessionCount >= $rateLimit && time() - $rateBlocked <= $sessionTime) { + /* if over limit, and block current, block them */ + return true; + } else if ($sessionCount < $rateLimit && time() - $rateTimeout <= $sessionTime) { + /* if under limit, and timeout current, allow them */ + return false; + } + + /* either over limit and block has expired or */ + /* under limit and timeout has expired, so reset them */ + $this->resetRateLimit(false); + return false; + } + /** * @return string returns a newly generated random uuid */ @@ -198,30 +258,26 @@ class Auth { */ private function login(): bool { /* fields must both be filled out */ - if (isset($this->get['token'], $this->get['id']) && $this->get['token'] && $this->get['id']) { + if (isset($this->get[self::TOKEN_FIELD], $this->get[self::ID_FIELD]) && + $this->get[self::TOKEN_FIELD] && $this->get[self::ID_FIELD] + ) { /* filter user input */ - $id = substr(preg_replace(self::URL64, '', $this->get['id']), 0, 100); + $id = substr(preg_replace(self::URL64, '', $this->get[self::ID_FIELD]), 0, 100); $otp = TOTP::createFromSecret($this->token); $date = date('Y-m-d H:i:s'); - /* determine real remote-host, if local address, find next level up */ - $remoteHost = $_SERVER['REMOTE_HOST']; - if (IpUtils::isPrivateIp($remoteHost)) { - $remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')); - $remoteIndex = array_search($remoteHost, $remoteList); - if ($remoteIndex > 0) { - $remoteHost = $remoteList[$remoteIndex - 1]; - } - } - /* if given token is valid, login, store session file and set the cookie */ - if ($otp->now() === $this->get['token']) { + if ($otp->now() === $this->get[self::TOKEN_FIELD]) { $this->id = $id; $uuid = $this->newUUID(); $rawIV = random_bytes(openssl_cipher_iv_length(self::CIPHER)); $rawUUID = openssl_encrypt($uuid, self::CIPHER, $this->key, 0, $rawIV); $encUUID = strtr(base64_encode($rawUUID), '-_', '+/'); - file_put_contents(self::BASE . $encUUID, base64_encode($rawIV) . "\$$id\$$this->expire\$$date\$$remoteHost\$\n"); + file_put_contents( + self::BASE . $encUUID, + base64_encode($rawIV) . + "\$$id\$$this->expire\$$date\${$this->getRemoteHost()}\$\n" + ); setcookie( self::NAME, $encUUID, @@ -232,12 +288,52 @@ class Auth { true /* no js access */ ); error_log("[$date] successful login by id: $id"); + /* successful login, reset the rate-limit and close */ + $this->resetRateLimit(); + return true; } - /* log failed logins, so we can fail2ban bad actors */ - error_log("[$date] failed login attempted by ip: $remoteHost"); + /* login attempted and failed, update monitoring for rate limiting */ + $this->logFailedAttempt(); } return false; } + + /** + * Determine real remote-host, if local address, find next level up + * @return string returns the real remote host (IP address) + */ + private function getRemoteHost(): string { + $remoteHost = $_SERVER['REMOTE_HOST']; + if (IpUtils::isPrivateIp($remoteHost)) { + $remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')); + $remoteIndex = array_search($remoteHost, $remoteList); + if ($remoteIndex > 0) { + $remoteHost = $remoteList[$remoteIndex - 1]; + } + } + return $remoteHost; + } + + /** + * [Re]Initialize the rate-limiting and close monitoring session (unless you pass false) + * @param bool $close Defaults to true, set to false to keep monitoring session open + */ + private function resetRateLimit(bool $close = true): void { + $_SESSION['time'] = time(); + $_SESSION['count'] = 0; + if ($close) { + session_write_close(); + } + } + + /** + * Note that login failed so we can determine if we should rate-limit future requests + */ + private function logFailedAttempt(): void { + $_SESSION['time'] = time(); + $_SESSION['count']++; + session_write_close(); + } } diff --git a/template.php b/template.php index 330e47a..8c5a70a 100644 --- a/template.php +++ b/template.php @@ -63,11 +63,11 @@ $env = new Env();

getTitle(); ?>

- +
-
+
-
+