patx/gitman
Limit repository resources and add secure password changes
Commit 5135684 · patx · 2026-11-02T22:06:41-05:00
Comments
No comments yet.
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