vextoly/gitman

Fork of patx/gitman.

Limit repository resources and add secure password changes

Commit 5135684 · patx · 2026-11-02T22:06:41-05:00

Changeset
51356844f3985c8957c50f7c83fa247fb99cdb94
Parents
9f3c4d2935c10806291b43c41bfe2457d501bb2b

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index eb12936..e31acb5 100644
--- a/README.md
+++ b/README.md
@@ -60,3 +60,23 @@ Repositories and their Git data live on local disk, so keep the database and rep
 ## License
 
 GitMan is licensed under the BSD 3-Clause License. See [LICENSE](https://gitman.io/patx/gitman/src/LICENSE).
+
+
+## Account passwords and resource limits
+
+Edit profile includes a Change password dialog. Changes require the current
+password, validate confirmation, and invalidate other browser sessions. Git HTTPS
+uses the new password immediately. Existing sessions survive upgrading until their
+account changes its password. Password changes use CSRF protection and failed-attempt
+throttling. Passwords are never included in rendered forms or logs.
+
+Repositories default to a 256 MiB storage cap (`GITMAN_MAX_REPO_BYTES`). Push hooks
+check existing and quarantined objects before accepting references; imports and
+forks check size before publishing. Incoming packs and streamed uploads are bounded.
+
+For a shared VPS, `deploy/70-resource-limits.conf` provides a systemd drop-in:
+75% of one CPU, 768 MiB memory throttling, 1 GiB hard memory limit, no swap, and
+128 tasks. The limits cover the service and its Git subprocesses. At the hard
+memory limit GitMan may restart; mail should run in a separate service. These are
+process resource limits, not an aggregate disk quota. The repository cap does not
+limit the number of repositories. Adjust these settings for your workload.
diff --git a/app.py b/app.py
index fc05fcc..3e5dcb8 100644
--- a/app.py
+++ b/app.py
@@ -22,6 +22,8 @@ from pathlib import Path, PurePosixPath
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlparse
 from wsgiref.simple_server import WSGIServer
 
+from repo_quota import enforce_repository_size, install_quota_hook
+
 import bleach
 import markdown
 try:
@@ -68,7 +70,8 @@ DEBUG = env_bool("GITMAN_DEBUG")
 PASSWORD_ITERATIONS = 260_000
 SQLITE_BUSY_TIMEOUT_MS = 30_000
 MAX_FORM_BYTES = env_int("GITMAN_MAX_FORM_BYTES", 64 * 1024)
-MAX_IMPORT_BYTES = env_int("GITMAN_MAX_IMPORT_BYTES", 2 * 1024 * 1024 * 1024)
+MAX_REPO_BYTES = env_int("GITMAN_MAX_REPO_BYTES", 256 * 1024 * 1024, minimum=1024 * 1024)
+MAX_IMPORT_BYTES = min(MAX_REPO_BYTES, env_int("GITMAN_MAX_IMPORT_BYTES", 2 * 1024 * 1024 * 1024) or MAX_REPO_BYTES)
 IMPORT_UPLOAD_CHUNK_BYTES = 1024 * 1024
 IMPORT_UPLOAD_STALE_SECONDS = env_int("GITMAN_IMPORT_UPLOAD_STALE_SECONDS", 6 * 60 * 60, minimum=60)
 GIT_IMPORT_TIMEOUT_SECONDS = env_int("GITMAN_IMPORT_TIMEOUT_SECONDS", 3600, minimum=1)
@@ -517,6 +520,7 @@ def init_db():
 def ensure_user_profile_columns(conn):
     columns = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
     profile_columns = {
+        "session_version": "ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 0",
         "display_name": "ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''",
         "bio": "ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT ''",
         "website": "ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT ''",
@@ -939,8 +943,15 @@ def too_many_requests_response():
 
 @app.hook("before_request")
 def load_current_user():
-    user_id = request.get_cookie("session", secret=SECRET_KEY)
-    request.environ["gitman.user"] = get_user_by_id(user_id) if user_id else None
+    token = request.get_cookie("session", secret=SECRET_KEY)
+    user = None
+    if isinstance(token, str):
+        user_id, separator, version = token.partition(":")
+        candidate = get_user_by_id(user_id)
+        # Existing signed cookies remain valid until that account changes its password.
+        if candidate and (version if separator else "0") == str(candidate["session_version"]):
+            user = candidate
+    request.environ["gitman.user"] = user
 
 
 @app.hook("before_request")
@@ -993,7 +1004,7 @@ def render(template_name, **context):
 def login_user(user):
     response.set_cookie(
         "session",
-        str(user["id"]),
+        f"{user['id']}:{user['session_version']}",
         secret=SECRET_KEY,
         httponly=True,
         secure=request_is_secure(),
@@ -1727,10 +1738,12 @@ def install_repo_hooks(path):
     post_receive = hooks_dir / "post-receive"
     post_receive.write_text(POST_RECEIVE_HOOK, encoding="utf-8")
     post_receive.chmod(0o755)
+    install_quota_hook(path, MAX_REPO_BYTES)
 
 
 def prepare_repo_for_receive(path):
     install_repo_hooks(path)
+    run_git(["config", "receive.maxInputSize", str(MAX_REPO_BYTES)], cwd=path)
     run_git(["config", "receive.denyDeleteCurrent", "warn"], cwd=path)
 
 
@@ -1850,6 +1863,7 @@ def create_repository(owner, name, description):
 def fork_repository(owner, source_repo, name, description):
     now = utcnow()
     source_path = repo_path(source_repo["owner_username"], source_repo["name"])
+    enforce_repository_size(source_path, MAX_REPO_BYTES)
     path = repo_path(owner["username"], name)
     if path.exists():
         raise ValueError("Repository directory already exists.")
@@ -1887,6 +1901,7 @@ def fork_repository(owner, source_repo, name, description):
 
     try:
         run_git(["clone", "--bare", str(source_path), str(path)], timeout=60)
+        enforce_repository_size(path, MAX_REPO_BYTES)
         write_git_metadata(path, owner["username"], name, description)
         repo = get_repo(owner["username"], name)
         if repo:
@@ -2279,6 +2294,7 @@ def import_git_bundle(repo, path, upload, import_upload_id="", import_status_alr
                 raise ValueError("Uploaded Git bundle does not contain any branches.")
             default_code_ref(staging_path)
             write_git_metadata(staging_path, owner, name, repo["description"])
+            enforce_repository_size(staging_path, MAX_REPO_BYTES)
 
             if not repo_is_empty(path):
                 raise ValueError("Repository is no longer empty.")
@@ -4309,12 +4325,14 @@ def merge_pull_request(pr, user):
                 raise GitCommandError(message, merge.returncode)
             merge_node = repo_tip_node(work_path) or source_node
 
-        push = run_git(
-            ["push", "origin", f"{merge_node}:refs/heads/{target_ref['name']}"],
-            cwd=work_path,
-            check=False,
-            timeout=60,
-        )
+        with repo_import_lock(target_repo):
+            prepare_repo_for_receive(target_path)
+            push = run_git(
+                ["push", "origin", f"{merge_node}:refs/heads/{target_ref['name']}"],
+                cwd=work_path,
+                check=False,
+                timeout=60,
+            )
         if push.returncode != 0:
             raise GitCommandError(push.stderr.strip() or "Unable to update target branch.", push.returncode)
 
@@ -4539,7 +4557,7 @@ def git_http_backend_executable():
     raise GitCommandError("git-http-backend executable was not found.")
 
 
-def spool_request_body(source, expected_size=0):
+def spool_request_body(source, expected_size=0, max_bytes=0):
     body_file = tempfile.TemporaryFile()
     size = 0
     remaining = expected_size if expected_size > 0 else None
@@ -4549,8 +4567,10 @@ def spool_request_body(source, expected_size=0):
             chunk = source.read(read_size)
             if not chunk:
                 break
-            body_file.write(chunk)
             size += len(chunk)
+            if max_bytes and size > max_bytes:
+                raise ValueError("Push upload exceeds the repository size limit.")
+            body_file.write(chunk)
             if remaining is not None:
                 remaining -= len(chunk)
         body_file.seek(0)
@@ -4572,7 +4592,7 @@ def git_http_backend_response(repo, auth_user, on_success=None, buffer_response=
     body_file = None
     body_size = 0
     if request.method == "POST":
-        body_file, body_size = spool_request_body(request.environ["wsgi.input"], request_content_length())
+        body_file, body_size = spool_request_body(request.environ["wsgi.input"], request_content_length(), MAX_REPO_BYTES + 1024 * 1024 if debug_receive else 0)
         if debug_receive:
             log_perf(
                 "git-receive-request",
@@ -4820,7 +4840,7 @@ def login():
     redirect(next_url)
 
 
[email protected]("/logout", method=["GET"])
[email protected]("/logout", method=["GET", "POST"])
 def logout():
     logout_user()
     redirect("/")
@@ -4851,6 +4871,42 @@ def edit_profile():
         return render("edit_profile.tpl", profile=values, error=str(exc))
 
 
[email protected]("/settings/password")
+def change_password():
+    user = require_login()
+    identifier = str(user["id"])
+    if rate_limit_blocked("password-change", identifier):
+        return too_many_requests_response()
+    current = request.forms.get("current_password", "")
+    new = request.forms.get("new_password", "")
+    confirmation = request.forms.get("confirm_password", "")
+    def invalid(message, status=400):
+        response.status = status
+        return render("edit_profile.tpl", profile=user, password_error=message)
+    if len(current) > 1024 or not verify_password(current, user["password_hash"]):
+        record_auth_failure("password-change", identifier)
+        return invalid("Current password is incorrect.")
+    if not 8 <= len(new) <= 1024:
+        return invalid("Use a password between 8 and 1024 characters.")
+    if new != confirmation:
+        return invalid("New passwords do not match.")
+    if new == current:
+        return invalid("Choose a different password.")
+    new_hash = hash_password(new)
+    with db_connect() as conn:
+        changed = conn.execute(
+            "UPDATE users SET password_hash=?, session_version=session_version+1 WHERE id=? AND password_hash=?",
+            (new_hash, user["id"], user["password_hash"]),
+        ).rowcount
+    if not changed:
+        return invalid("Your password changed in another session. Sign in again.", 409)
+    clear_auth_failures("password-change", identifier)
+    refreshed = get_user_by_id(user["id"])
+    request.environ["gitman.user"] = refreshed
+    login_user(refreshed)
+    return render("edit_profile.tpl", profile=refreshed, notice="Password changed. Other browser sessions are signed out.")
+
+
 @app.route("/new", method=["GET", "POST"])
 def new_repo():
     user = require_login()
@@ -5711,12 +5767,14 @@ def git_http(owner, repo_name, git_path=""):
         schedule_repo_metadata_refresh(repo["id"])
 
     try:
-        return git_http_backend_response(
-            repo,
-            auth_user,
-            on_success=after_successful_write if is_write else None,
-            buffer_response=is_write,
-        )
+        if is_write and request.method == "POST":
+            # Keep admission, quarantine migration and ref updates atomic relative
+            # to other HTTP pushes, imports and pull-request merges for this repo.
+            with repo_import_lock(repo):
+                return git_http_backend_response(
+                    repo, auth_user, on_success=after_successful_write, buffer_response=True,
+                )
+        return git_http_backend_response(repo, auth_user)
     except ValueError as exc:
         return HTTPResponse(f"{exc}\n", status=400, content_type="text/plain; charset=utf-8")
 
diff --git a/deploy/70-resource-limits.conf b/deploy/70-resource-limits.conf
new file mode 100644
index 0000000..c9558cc
--- /dev/null
+++ b/deploy/70-resource-limits.conf
@@ -0,0 +1,12 @@
+# Install as /etc/systemd/system/gitman.service.d/70-resource-limits.conf
+[Service]
+CPUQuota=75%
+CPUWeight=25
+MemoryHigh=768M
+MemoryMax=1G
+MemorySwapMax=0
+TasksMax=128
+IOWeight=25
+OOMPolicy=kill
+Restart=always
+RestartSec=5
diff --git a/repo_quota.py b/repo_quota.py
new file mode 100644
index 0000000..96909cf
--- /dev/null
+++ b/repo_quota.py
@@ -0,0 +1,68 @@
+"""Repository storage admission checks shared by imports, forks and Git hooks."""
+import os
+from pathlib import Path
+import stat
+import sys
+
+RESERVE_BYTES = 64 * 1024
+
+
+def repository_bytes(path):
+    total = 0
+    def failed_scan(error):
+        raise error
+    for directory, dirs, files in os.walk(path, followlinks=False, onerror=failed_scan):
+        for name in files:
+            try:
+                info = (Path(directory) / name).lstat()
+            except FileNotFoundError:
+                continue  # A concurrent receive may have removed its quarantine.
+            if stat.S_ISREG(info.st_mode):
+                total += info.st_size
+    return total
+
+
+def enforce_repository_size(path, limit, reserve=RESERVE_BYTES):
+    size = repository_bytes(path)
+    if size + reserve > limit:
+        raise ValueError(f"Repository size limit is {limit / 1048576:g} MiB. "
+                         "This operation would exceed the limit (including room for Git metadata).")
+
+
+def install_quota_hook(path, limit):
+    hook = Path(path) / 'hooks' / 'pre-receive'
+    content = ('#!/usr/bin/env python3\n# GitMan repository storage quota\n'
+               f'import sys\nsys.path.insert(0, {str(Path(__file__).resolve().parent)!r})\n'
+               f'from repo_quota import check_receive\nsys.exit(check_receive({int(limit)}))\n')
+    if hook.exists():
+        previous = hook.read_text()
+        if previous == content and os.access(hook, os.X_OK):
+            return
+        if 'GitMan repository storage quota' not in previous:
+            raise ValueError('Existing pre-receive hook requires manual integration; it was not overwritten.')
+    # Use a unique temporary file so concurrent receive setup cannot truncate it.
+    import tempfile
+    fd, temporary = tempfile.mkstemp(prefix='.quota-', dir=hook.parent)
+    try:
+        with os.fdopen(fd, 'w') as stream:
+            stream.write(content)
+        os.chmod(temporary, 0o755)
+        os.replace(temporary, hook)
+    finally:
+        if os.path.exists(temporary):
+            os.unlink(temporary)
+
+
+def check_receive(limit):
+    updates = [line.split() for line in sys.stdin if line.strip()]
+    if updates and all(len(row) == 3 and set(row[1]) == {'0'} for row in updates):
+        return 0  # Deleting references remains possible even at the limit.
+    try:
+        # receive-pack quarantine resides inside objects/, so this counts both
+        # existing storage and new objects before any branch/tag is accepted.
+        reserve = RESERVE_BYTES + sum(len(row[-1].encode()) * 2 + 1024 for row in updates)
+        enforce_repository_size(Path.cwd(), limit, reserve)
+    except (OSError, ValueError) as exc:
+        print(f'GitMan: {exc}', file=sys.stderr)
+        return 1
+    return 0
diff --git a/static/profile.js b/static/profile.js
new file mode 100644
index 0000000..bb52a2a
--- /dev/null
+++ b/static/profile.js
@@ -0,0 +1,14 @@
+(() => {
+  const dialog = document.getElementById('password-dialog');
+  const open = document.getElementById('change-password-open');
+  if (!dialog || !open) return;
+  open.addEventListener('click', () => dialog.showModal());
+  document.getElementById('change-password-cancel').addEventListener('click', () => dialog.close());
+  dialog.addEventListener('close', () => {
+    dialog.querySelector('form').reset();
+    const error = dialog.querySelector('[role="alert"]');
+    if (error) error.remove();
+    open.focus();
+  });
+  if (dialog.dataset.reopen === 'true') dialog.showModal();
+})();
diff --git a/static/styles.css b/static/styles.css
index 99224bd..c2e0bd8 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -379,3 +379,23 @@ button, .button {
   .repo-search-menu { left: 0; right: auto; width: 100%; }
   .grid.two, .commit-list li, .tag-list li { grid-template-columns: 1fr; }
 }
+
+.profile-security { margin: 1.5rem 0; }
+.password-dialog {
+  width: min(28rem, calc(100% - 2rem));
+  max-height: calc(100dvh - 2rem);
+  overflow: auto;
+  padding: 1.5rem;
+  border: 1px solid var(--strong-border);
+  border-radius: .5rem;
+  color: var(--text);
+  background: var(--surface);
+  box-shadow: 0 12px 48px var(--shadow);
+}
+.password-dialog::backdrop { background: rgb(0 0 0 / .45); }
+.password-dialog h2 { font-size: 1.25rem; margin-bottom: 1rem; }
+.password-dialog label { display: block; margin-bottom: 1rem; }
+.password-dialog input { margin-top: .25rem; }
+.password-dialog .muted { font-size: .875rem; }
+.password-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .75rem; }
+.password-dialog form { margin-bottom: 0; }
diff --git a/templates/base.tpl b/templates/base.tpl
index e7be9ac..4889f5c 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -9,7 +9,7 @@
 <link rel="apple-touch-icon" sizes="180x180" href="/static/git.svg">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
-  <link rel="stylesheet" href="/static/styles.css?v=5">
+  <link rel="stylesheet" href="/static/styles.css?v=6">
 </head>
 <body>
   <header class="site-header">
diff --git a/templates/edit_profile.tpl b/templates/edit_profile.tpl
index 94c34ac..d4ef85d 100644
--- a/templates/edit_profile.tpl
+++ b/templates/edit_profile.tpl
@@ -18,6 +18,32 @@
     </label>
     <button class="button" type="submit">Save profile</button>
   </form>
-  <br><br>
+  <p class="profile-security"><button type="button" id="change-password-open">Change password</button></p>
   <p class="muted"><a href="/{{profile['username']}}"><- Back</a></p>
 </section>
+
+<dialog id="password-dialog" class="password-dialog" aria-labelledby="password-title" data-reopen="{{'true' if get('password_error') else 'false'}}">
+  <h2 id="password-title">Change password</h2>
+  <form method="post" action="/settings/password">
+    {{!csrf_field()}}
+    <input type="text" name="username" value="{{user['username']}}" autocomplete="username" hidden>
+    % if get('password_error'):
+      <p class="alert" role="alert">{{password_error}}</p>
+    % end
+    <label>Current password
+      <input type="password" name="current_password" autocomplete="current-password" maxlength="1024" required autofocus>
+    </label>
+    <label>New password
+      <input type="password" name="new_password" autocomplete="new-password" minlength="8" maxlength="1024" required aria-describedby="password-hint">
+    </label>
+    <label>Confirm new password
+      <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="1024" required>
+    </label>
+    <p class="muted" id="password-hint">At least 8 characters. Other browser sessions will be signed out.</p>
+    <div class="password-actions">
+      <button type="button" id="change-password-cancel">Cancel</button>
+      <button type="submit">Change password</button>
+    </div>
+  </form>
+</dialog>
+<script src="/static/profile.js?v=1" defer></script>
diff --git a/tests/test_app.py b/tests/test_app.py
index 7371c4b..fddb90a 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2645,3 +2645,105 @@ def test_git_http_routes_are_public_for_reads_and_protect_writes(isolated_app):
     assert b"git-receive-pack" in response.body
     assert hook.exists()
     assert isolated_app.run_git(["config", "receive.denyDeleteCurrent"], cwd=path).stdout.strip() == "warn"
+
+
+def test_repo_quota_rejects_growth_before_refs_change_and_cleans_quarantine(isolated_app, monkeypatch):
+    from repo_quota import repository_bytes, RESERVE_BYTES
+    owner = create_user('quotaowner')
+    isolated_app.create_repository(owner, 'quota', '')
+    path = isolated_app.repo_path('quotaowner', 'quota')
+    original = commit_file(path, 'first.bin', os.urandom(80000).hex(), user='quotaowner')
+    monkeypatch.setattr(isolated_app, 'MAX_REPO_BYTES', repository_bytes(path) + RESERVE_BYTES + 16000)
+    isolated_app.prepare_repo_for_receive(path)
+    before = repository_bytes(path)
+    with pytest.raises(gitman.GitCommandError, match='Repository size limit'):
+        commit_file(path, 'second.bin', os.urandom(40000).hex(), user='quotaowner')
+    assert isolated_app.repo_tip_node(path) == original
+    assert repository_bytes(path) == before
+    assert not list((path / 'objects').glob('tmp_objdir-incoming-*'))
+    # Existing clients can still clone/read; a small update can still fit.
+    updated = commit_file(path, 'small.txt', 'Small permitted change', user='quotaowner')
+    assert updated != original and repository_bytes(path) < isolated_app.MAX_REPO_BYTES
+
+
+def test_repo_quota_rejects_import_and_fork_without_replacing_existing_data(isolated_app, monkeypatch, tmp_path):
+    owner = create_user('quotaowner')
+    isolated_app.create_repository(owner, 'source', '')
+    isolated_app.create_repository(owner, 'target', '')
+    source = isolated_app.repo_path('quotaowner', 'source')
+    target = isolated_app.repo_path('quotaowner', 'target')
+    original = commit_file(source, 'data.bin', os.urandom(40000).hex(), user='quotaowner')
+    bundle = tmp_path / 'quota.bundle'
+    isolated_app.run_git(['bundle', 'create', str(bundle), '--all'], cwd=source)
+    monkeypatch.setattr(isolated_app, 'MAX_REPO_BYTES', 70 * 1024)
+    source_repo = isolated_app.get_repo('quotaowner', 'source')
+    with pytest.raises(ValueError, match='Repository size limit'):
+        isolated_app.fork_repository(owner, source_repo, 'fork', '')
+    assert isolated_app.get_repo('quotaowner', 'fork') is None
+    assert isolated_app.repo_tip_node(source) == original
+    with bundle.open('rb') as stream, pytest.raises(ValueError, match='Repository size limit'):
+        isolated_app.import_git_bundle(isolated_app.get_repo('quotaowner', 'target'), target,
+            gitman.StreamingUpload('quota.bundle', stream))
+    assert isolated_app.repo_is_empty(target)
+    assert not list(target.parent.glob('.target-import-*'))
+
+
+def test_git_upload_spooling_stops_at_bound_without_declared_length():
+    with pytest.raises(ValueError, match='size limit'):
+        gitman.spool_request_body(BytesIO(b'x' * 1025), max_bytes=1024)
+
+
+def test_change_password_revokes_other_sessions_and_git_credentials(isolated_app):
+    from bottle import cookie_encode
+    owner = create_user('alice')
+    isolated_app.create_repository(owner, 'private', '')
+    client, other, legacy = [WsgiClient(isolated_app.app) for _ in range(3)]
+    for browser in (client, other):
+        assert login_client(browser, 'alice').status_code == 303
+    legacy.cookies['session'] = cookie_encode(('session', str(owner['id'])), gitman.SECRET_KEY).decode()
+    assert legacy.get('/settings/profile').status_code == 200
+    client.get('/settings/profile')
+    result = client.post('/settings/password', {'current_password': 'correct horse battery staple',
+        'new_password': 'a different secure password', 'confirm_password': 'a different secure password'})
+    assert result.status_code == 200 and 'Password changed.' in result.text
+    assert client.get('/settings/profile').status_code == 200
+    for browser in (other, legacy):
+        assert browser.get('/settings/profile').status_code == 303
+    fresh = WsgiClient(isolated_app.app)
+    assert login_client(fresh, 'alice').status_code == 200
+    assert login_client(fresh, 'alice', 'a different secure password').status_code == 303
+    path = '/git/alice/private/info/refs?service=git-receive-pack'
+    assert WsgiClient(isolated_app.app).get(path, headers=basic_auth('alice', 'correct horse battery staple')).status_code == 401
+    assert WsgiClient(isolated_app.app).get(path, headers=basic_auth('alice', 'a different secure password')).status_code == 200
+
+
[email protected]('current,new,confirmation', [
+    ('wrong', 'new-password', 'new-password'),
+    ('correct horse battery staple', 'short', 'short'),
+    ('correct horse battery staple', 'new-password', 'different'),
+    ('correct horse battery staple', 'correct horse battery staple', 'correct horse battery staple'),
+    ('correct horse battery staple', 'x' * 1025, 'x' * 1025),
+])
+def test_change_password_rejects_invalid_input_without_changing_credentials(isolated_app, current, new, confirmation):
+    owner = create_user('alice')
+    client = WsgiClient(isolated_app.app)
+    login_client(client, 'alice')
+    client.get('/settings/profile')
+    result = client.post('/settings/password', {'current_password':current, 'new_password':new, 'confirm_password':confirmation})
+    assert result.status_code == 400
+    assert 'data-reopen="true"' in result.text
+    assert 'value="'+new+'"' not in result.text
+    assert isolated_app.get_user_by_id(owner['id'])['password_hash'] == owner['password_hash']
+
+
+def test_change_password_requires_login_csrf_and_throttles_guessing(isolated_app):
+    client = WsgiClient(isolated_app.app)
+    client.get('/login')
+    assert client.post('/settings/password').status_code == 303
+    create_user('alice')
+    login_client(client, 'alice')
+    client.get('/settings/profile')
+    assert client.post('/settings/password', {'_csrf_token':'invalid'}).status_code == 403
+    for _ in range(gitman.RATE_LIMIT_MAX_FAILURES):
+        assert client.post('/settings/password', {'current_password':'wrong'}).status_code == 400
+    assert client.post('/settings/password', {'current_password':'correct horse battery staple', 'new_password':'new-password', 'confirm_password':'new-password'}).status_code == 429