Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deploy/etc/msg-lmm-best/msg.conf
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ max_site_bytes = 10485760
[ssh]
shell_command = /usr/local/bin/msg-ssh-shell
max_keys_per_identity = 16
# Bounds the lifetime of Git-over-SSH operations; push bytes use repos.max_request_bytes.
git_timeout_seconds = 120

[limits]
max_post_bytes = 16384
Expand Down
5 changes: 5 additions & 0 deletions src/msgd/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class Config:
# OpenSSH restricted-shell integration.
ssh_shell_command: str = "/usr/local/bin/msg-ssh-shell"
ssh_max_keys_per_identity: int = 16
ssh_git_timeout_seconds: int = 120

# Logical capacity of active + archived post bodies and attachments.
# Normal delete archives; new writes reclaim oldest archives first when full.
Expand Down Expand Up @@ -143,6 +144,9 @@ def get(section: str, key: str, default):
ssh_max_keys_per_identity=get(
"ssh", "max_keys_per_identity", base.ssh_max_keys_per_identity
),
ssh_git_timeout_seconds=get(
"ssh", "git_timeout_seconds", base.ssh_git_timeout_seconds
),
max_storage_bytes=get("storage", "max_storage_bytes", base.max_storage_bytes),
max_post_bytes=get("limits", "max_post_bytes", base.max_post_bytes),
max_post_bytes_post=get("limits", "max_post_bytes_post", base.max_post_bytes_post),
Expand Down Expand Up @@ -208,6 +212,7 @@ def validate(self) -> None:
"repo_max_request_bytes",
"web_max_site_bytes",
"ssh_max_keys_per_identity",
"ssh_git_timeout_seconds",
):
if getattr(self, key) < 1:
raise SystemExit(f"{key} must be >= 1")
Expand Down
51 changes: 47 additions & 4 deletions src/msgd/sshaccess.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import threading
import time
import unicodedata
from contextlib import suppress
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
Expand Down Expand Up @@ -476,12 +477,54 @@ def _run_git_command(cfg: Config, item: dict[str, object], argv: list[str]) -> i
git = service._require_git()
env = os.environ.copy()
env["REMOTE_USER"] = str(item["owner_id"])
result = subprocess.run(
[git, "receive-pack" if receive else "upload-pack", str(path)],
if not receive:
result = subprocess.run(
[git, "upload-pack", str(path)],
env=env,
check=False,
)
return int(result.returncode)

process = subprocess.Popen(
[git, "receive-pack", str(path)],
env=env,
check=False,
stdin=subprocess.PIPE,
)
return int(result.returncode)
assert process.stdin is not None
exceeded = threading.Event()

def relay_input() -> None:
remaining = cfg.repo_max_request_bytes
try:
while True:
chunk = sys.stdin.buffer.read1(min(65_536, remaining + 1))
if not chunk:
break
if len(chunk) > remaining:
exceeded.set()
process.kill()
break
remaining -= len(chunk)
process.stdin.write(chunk)
process.stdin.flush()
except (BrokenPipeError, OSError, ValueError):
pass
finally:
with suppress(BrokenPipeError, OSError, ValueError):
process.stdin.close()

relay = threading.Thread(target=relay_input, name="ssh-git-input", daemon=True)
relay.start()
try:
return_code = process.wait(timeout=cfg.ssh_git_timeout_seconds)
except subprocess.TimeoutExpired as exc:
process.kill()
process.wait()
raise StoreError("Git SSH backend timed out", 504) from exc
relay.join(timeout=1)
if exceeded.is_set():
raise StoreError("Git SSH request too large", 413)
return int(return_code)


def _local_get(cfg: Config, path: str) -> int:
Expand Down
39 changes: 39 additions & 0 deletions tests/test_sshaccess.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,45 @@ def test_git_ssh_command_enforces_repository_scopes(self) -> None:
_run_git_command(self.cfg, item, ["git-receive-pack", "demo.git"])
self.assertEqual(ctx.exception.status, 403)

def test_git_ssh_receive_limits_input_and_runtime(self) -> None:
service = RepoService(self.cfg)
if not service.available:
self.skipTest("git executable unavailable")
item = {"owner_id": self.owner, "scopes": ("repo-write",)}

class Process:
returncode = 0

def __init__(self) -> None:
self.stdin = io.BytesIO()
self.killed = False

def wait(self, timeout=None):
self.timeout = timeout
return self.returncode

def kill(self):
self.killed = True

process = Process()
cfg = Config(
database=self.cfg.database,
repo_root=self.cfg.repo_root,
repo_max_request_bytes=3,
ssh_git_timeout_seconds=17,
)
service.ensure_repository("demo")
stdin = io.TextIOWrapper(io.BytesIO(b"four"))
with (
patch("msgd.sshaccess.subprocess.Popen", return_value=process),
patch("msgd.sshaccess.sys.stdin", stdin),
self.assertRaises(StoreError) as ctx,
):
_run_git_command(cfg, item, ["git-receive-pack", "demo.git"])
self.assertEqual(ctx.exception.status, 413)
self.assertTrue(process.killed)
self.assertEqual(process.timeout, 17)

def test_expired_key_does_not_authenticate(self) -> None:
key = public_key()
expires = int(time.time()) + 60
Expand Down