You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/04/09 10:29:04 UTC
(tooling-trusted-releases) branch ssh_rate_limiting created (now a9f3a724)
This is an automated email from the ASF dual-hosted git repository.
arm pushed a change to branch ssh_rate_limiting
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
at a9f3a724 #1003 - add rate limiting to SSH connections
This branch includes the following new commits:
new a9f3a724 #1003 - add rate limiting to SSH connections
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #1003 - add rate limiting to SSH connections
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
arm pushed a commit to branch ssh_rate_limiting
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit a9f3a72473f895e9dcf2d7fab5f6cb5498a1f4d9
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Apr 9 11:26:49 2026 +0100
#1003 - add rate limiting to SSH connections
---
atr/ssh.py | 38 +++++++-
tests/unit/test_ssh_rate_limiting.py | 174 +++++++++++++++++++++++++++++++++++
2 files changed, 208 insertions(+), 4 deletions(-)
diff --git a/atr/ssh.py b/atr/ssh.py
index 948fa6b4..ddc5a423 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -19,6 +19,7 @@
import asyncio
import asyncio.subprocess
+import collections
import datetime
import glob
import os
@@ -63,6 +64,15 @@ _PATH_ALPHANUM: Final = frozenset(string.ascii_letters + string.digits + "-")
# We also allow + which is in common use
_PATH_VERSION_CHARS: Final = _PATH_ALPHANUM | frozenset(".-+")
+_RATE_LIMIT_IP: Final = 100
+_RATE_LIMIT_USER: Final = 10
+_RATE_WINDOW: Final = 60.0
+
+# Keyed by IP address; catches all connections including failed auth
+global_ip_rate_buckets: dict[str, collections.deque[float]] = {}
+# Keyed by ASF UID; only incremented on successful authentication
+global_user_rate_buckets: dict[str, collections.deque[float]] = {}
+
class RsyncArgsError(Exception):
"""Exception raised when the rsync arguments are invalid."""
@@ -75,12 +85,15 @@ class SSHServer(asyncssh.SSHServer):
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
"""Called when a connection is established."""
- # Store connection for use in begin_auth
self._conn = conn
self._github_asf_uid: str | None = None
self._github_payload: github.TrustedPublisherPayload | None = None
peer_addr = conn.get_extra_info("peername")[0]
log.info(f"SSH connection received from {peer_addr}")
+ if not _rate_limit_check(global_ip_rate_buckets, peer_addr, _RATE_LIMIT_IP):
+ log.warning(f"IP rate limit exceeded for {peer_addr}")
+ conn.disconnect(asyncssh.DISC_TOO_MANY_CONNECTIONS, "Rate limit exceeded")
+ return
def connection_lost(self, exc: Exception | None) -> None:
"""Called when a connection is lost or closed."""
@@ -136,11 +149,16 @@ class SSHServer(asyncssh.SSHServer):
def auth_completed(self) -> None:
username = self._conn.get_extra_info("username")
- type = "ssh"
+ auth_type = "ssh"
if username == "github":
- type = "githubssh"
+ auth_type = "githubssh"
username = self._github_asf_uid
- log.auth_success(type, username)
+ if username and not _rate_limit_check(global_user_rate_buckets, username, _RATE_LIMIT_USER):
+ log.warning(f"User rate limit exceeded for {username}")
+ log.auth_failure(auth_type, "rate_limit_exceeded", username)
+ self._conn.disconnect(asyncssh.DISC_TOO_MANY_CONNECTIONS, "Rate limit exceeded")
+ return
+ log.auth_success(auth_type, username)
def public_key_auth_supported(self) -> bool:
"""Indicate whether public key authentication is supported."""
@@ -251,6 +269,18 @@ def _output_stderr(process: asyncssh.SSHServerProcess, message: str) -> None:
log.exception(f"Error writing to client stderr: {e}")
+def _rate_limit_check(bucket: dict[str, collections.deque[float]], key: str, limit: int) -> bool:
+ """Return True if key is within the rate limit, False if exceeded. Mutates bucket."""
+ now = time.monotonic()
+ window = bucket.setdefault(key, collections.deque())
+ while window and (now - window[0]) > _RATE_WINDOW:
+ window.popleft()
+ if len(window) >= limit:
+ return False
+ window.append(now)
+ return True
+
+
async def _step_01_handle_client(process: asyncssh.SSHServerProcess, server: SSHServer) -> None:
"""Process client command, validating and dispatching to read or write handlers."""
try:
diff --git a/tests/unit/test_ssh_rate_limiting.py b/tests/unit/test_ssh_rate_limiting.py
new file mode 100644
index 00000000..9158be0c
--- /dev/null
+++ b/tests/unit/test_ssh_rate_limiting.py
@@ -0,0 +1,174 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for SSH rate limiting."""
+
+import unittest.mock as mock
+from typing import TYPE_CHECKING
+
+import asyncssh
+import pytest
+
+import atr.ssh as ssh
+
+if TYPE_CHECKING:
+ import collections
+
+ from pytest import MonkeyPatch
+
+
+@pytest.fixture(autouse=True)
+def _clear_rate_buckets():
+ """Clear global rate limit buckets before each test to prevent state leakage."""
+ ssh.global_ip_rate_buckets.clear()
+ ssh.global_user_rate_buckets.clear()
+ yield
+ ssh.global_ip_rate_buckets.clear()
+ ssh.global_user_rate_buckets.clear()
+
+
+# _rate_limit_check
+
+
+def test_rate_limit_check_allows_within_limit():
+ bucket: dict[str, collections.deque[float]] = {}
+ for _ in range(ssh._RATE_LIMIT_USER):
+ assert ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER) is True
+
+
+def test_rate_limit_check_blocks_when_limit_reached():
+ bucket: dict[str, collections.deque[float]] = {}
+ for _ in range(ssh._RATE_LIMIT_USER):
+ ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER)
+ assert ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER) is False
+
+
+def test_rate_limit_check_allows_after_window_expires(monkeypatch: "MonkeyPatch"):
+ current_time = [1000.0]
+ monkeypatch.setattr("atr.ssh.time.monotonic", lambda: current_time[0])
+ bucket: dict[str, collections.deque[float]] = {}
+ for _ in range(ssh._RATE_LIMIT_USER):
+ ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER)
+ assert ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER) is False
+
+ current_time[0] = 1000.0 + ssh._RATE_WINDOW + 1.0
+ assert ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER) is True
+
+
+def test_rate_limit_check_keys_are_independent():
+ bucket: dict[str, collections.deque[float]] = {}
+ for _ in range(ssh._RATE_LIMIT_USER):
+ ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER)
+ assert ssh._rate_limit_check(bucket, "alice", ssh._RATE_LIMIT_USER) is False
+ assert ssh._rate_limit_check(bucket, "bob", ssh._RATE_LIMIT_USER) is True
+
+
+# connection_made — IP rate limit
+
+
+def test_connection_made_allows_within_ip_limit():
+ server = _make_server()
+ conn = _make_conn_with_ip("1.2.3.4")
+ server.connection_made(conn)
+ conn.disconnect.assert_not_called()
+
+
+def test_connection_made_blocks_when_ip_limit_exceeded():
+ for _ in range(ssh._RATE_LIMIT_IP):
+ server = _make_server()
+ server.connection_made(_make_conn_with_ip("1.2.3.4"))
+ server = _make_server()
+ conn = _make_conn_with_ip("1.2.3.4")
+ server.connection_made(conn)
+ conn.disconnect.assert_called_once_with(asyncssh.DISC_TOO_MANY_CONNECTIONS, "Rate limit exceeded")
+
+
+def test_connection_made_different_ips_are_independent():
+ for _ in range(ssh._RATE_LIMIT_IP):
+ server = _make_server()
+ server.connection_made(_make_conn_with_ip("1.2.3.4"))
+ server = _make_server()
+ conn = _make_conn_with_ip("5.6.7.8")
+ server.connection_made(conn)
+ conn.disconnect.assert_not_called()
+
+
+# auth_completed — user rate limit
+
+
+def test_auth_completed_allows_within_user_limit():
+ server = _make_server()
+ server._conn = _make_conn_with_username("alice")
+ server.auth_completed()
+ server._conn.disconnect.assert_not_called()
+
+
+def test_auth_completed_blocks_when_user_limit_exceeded():
+ for _ in range(ssh._RATE_LIMIT_USER):
+ server = _make_server()
+ server._conn = _make_conn_with_username("alice")
+ server.auth_completed()
+ server = _make_server()
+ server._conn = _make_conn_with_username("alice")
+ server.auth_completed()
+ server._conn.disconnect.assert_called_once_with(asyncssh.DISC_TOO_MANY_CONNECTIONS, "Rate limit exceeded")
+
+
+def test_auth_completed_github_uses_asf_uid_for_rate_limit():
+ for _ in range(ssh._RATE_LIMIT_USER):
+ server = _make_server()
+ server._conn = _make_conn_with_username("github")
+ server._github_asf_uid = "alice"
+ server.auth_completed()
+ server = _make_server()
+ server._conn = _make_conn_with_username("github")
+ server._github_asf_uid = "alice"
+ server.auth_completed()
+ server._conn.disconnect.assert_called_once_with(asyncssh.DISC_TOO_MANY_CONNECTIONS, "Rate limit exceeded")
+
+
+def test_auth_completed_different_users_are_independent():
+ for _ in range(ssh._RATE_LIMIT_USER):
+ server = _make_server()
+ server._conn = _make_conn_with_username("alice")
+ server.auth_completed()
+ server = _make_server()
+ server._conn = _make_conn_with_username("bob")
+ server.auth_completed()
+ server._conn.disconnect.assert_not_called()
+
+
+# helpers
+
+
+def _make_conn_with_ip(ip: str) -> mock.MagicMock:
+ conn = mock.MagicMock(spec=asyncssh.SSHServerConnection)
+ conn.get_extra_info.return_value = (ip, 22)
+ return conn
+
+
+def _make_conn_with_username(username: str) -> mock.MagicMock:
+ conn = mock.MagicMock(spec=asyncssh.SSHServerConnection)
+ conn.get_extra_info.return_value = username
+ return conn
+
+
+def _make_server() -> ssh.SSHServer:
+ server = ssh.SSHServer.__new__(ssh.SSHServer)
+ server._github_asf_uid = None
+ server._github_payload = None
+ return server
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org