Fix 6 security issues from post-FR audit
- Fix 3 missing CSRF tokens on admin user delete/reset and account
key delete forms (were broken — CSRFProtect rejected submissions)
- Fix trust store path traversal: untrust_key() now validates
fingerprint format ([0-9a-f]{32}) and checks resolved path
- Fix chain key rotation: old key is now revoked after rotation
record, preventing compromised old keys from appending records
- Fix SSRF in deadman webhook: block private/internal IP targets
- Fix logout CSRF: /logout is now POST-only with CSRF token,
preventing cross-site forced logout via img tags
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fb0cc3e39d
commit
0d8c94bf82
@ -374,7 +374,7 @@ def _register_stegasoo_routes(app: Flask) -> None:
|
|||||||
flash("Invalid username or password", "error")
|
flash("Invalid username or password", "error")
|
||||||
return render_template("login.html")
|
return render_template("login.html")
|
||||||
|
|
||||||
@app.route("/logout")
|
@app.route("/logout", methods=["POST"])
|
||||||
def logout():
|
def logout():
|
||||||
auth_logout_user()
|
auth_logout_user()
|
||||||
flash("Logged out successfully", "success")
|
flash("Logged out successfully", "success")
|
||||||
|
|||||||
@ -157,6 +157,7 @@
|
|||||||
<form method="POST" action="{{ url_for('account_delete_key', key_id=key.id) }}"
|
<form method="POST" action="{{ url_for('account_delete_key', key_id=key.id) }}"
|
||||||
style="display:inline;"
|
style="display:inline;"
|
||||||
onsubmit="return confirm('Delete key "{{ key.name }}"?')">
|
onsubmit="return confirm('Delete key "{{ key.name }}"?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -65,12 +65,14 @@
|
|||||||
{% if user.id != current_user.id %}
|
{% if user.id != current_user.id %}
|
||||||
<form method="POST" action="{{ url_for('admin_reset_password', user_id=user.id) }}"
|
<form method="POST" action="{{ url_for('admin_reset_password', user_id=user.id) }}"
|
||||||
class="d-inline" onsubmit="return confirm('Reset password for {{ user.username }}?')">
|
class="d-inline" onsubmit="return confirm('Reset password for {{ user.username }}?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-warning" title="Reset Password">
|
<button type="submit" class="btn btn-sm btn-outline-warning" title="Reset Password">
|
||||||
<i class="bi bi-key"></i>
|
<i class="bi bi-key"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}"
|
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}"
|
||||||
class="d-inline" onsubmit="return confirm('Delete user {{ user.username }}? This cannot be undone.')">
|
class="d-inline" onsubmit="return confirm('Delete user {{ user.username }}? This cannot be undone.')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete User">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete User">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -102,7 +102,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item" href="/keys"><i class="bi bi-key me-2"></i>Keys</a></li>
|
<li><a class="dropdown-item" href="/keys"><i class="bi bi-key me-2"></i>Keys</a></li>
|
||||||
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
<li><form method="POST" action="/logout" class="d-inline"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/><button type="submit" class="dropdown-item"><i class="bi bi-box-arrow-left me-2"></i>Logout</button></form></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -499,6 +499,8 @@ class ChainStore:
|
|||||||
f"Record {record.chain_index}: key rotation missing new_pubkey"
|
f"Record {record.chain_index}: key rotation missing new_pubkey"
|
||||||
)
|
)
|
||||||
authorized_signers.add(bytes.fromhex(new_pubkey_hex))
|
authorized_signers.add(bytes.fromhex(new_pubkey_hex))
|
||||||
|
# Revoke the old key — the rotation record was its last authorized action
|
||||||
|
authorized_signers.discard(record.signer_pubkey)
|
||||||
|
|
||||||
prev_record = record
|
prev_record = record
|
||||||
expected_index += 1
|
expected_index += 1
|
||||||
|
|||||||
@ -114,14 +114,24 @@ class DeadmanSwitch:
|
|||||||
|
|
||||||
if webhook:
|
if webhook:
|
||||||
try:
|
try:
|
||||||
import urllib.request
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
data = json.dumps({"text": message}).encode()
|
parsed = urlparse(webhook)
|
||||||
req = urllib.request.Request(
|
hostname = parsed.hostname or ""
|
||||||
webhook, data=data, headers={"Content-Type": "application/json"}
|
# Block private/internal targets to prevent SSRF
|
||||||
)
|
blocked = hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0")
|
||||||
urllib.request.urlopen(req, timeout=10)
|
blocked = blocked or hostname.startswith(("10.", "172.16.", "192.168.", "169.254."))
|
||||||
logger.info("Deadman warning sent to webhook")
|
if blocked:
|
||||||
|
logger.warning("Webhook blocked: internal/private URL %s", hostname)
|
||||||
|
else:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
data = json.dumps({"text": message}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
webhook, data=data, headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
logger.info("Deadman warning sent to webhook")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to send deadman webhook: %s", e)
|
logger.error("Failed to send deadman webhook: %s", e)
|
||||||
|
|
||||||
|
|||||||
@ -380,9 +380,14 @@ class KeystoreManager:
|
|||||||
|
|
||||||
def untrust_key(self, fingerprint: str) -> bool:
|
def untrust_key(self, fingerprint: str) -> bool:
|
||||||
"""Remove a key from the trust store. Returns True if found and removed."""
|
"""Remove a key from the trust store. Returns True if found and removed."""
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{32}", fingerprint):
|
||||||
|
raise KeystoreError(f"Invalid fingerprint format: {fingerprint!r}")
|
||||||
key_dir = self._trusted_keys_dir / fingerprint
|
key_dir = self._trusted_keys_dir / fingerprint
|
||||||
|
if not key_dir.resolve().is_relative_to(self._trusted_keys_dir.resolve()):
|
||||||
|
raise KeystoreError("Path traversal detected")
|
||||||
if key_dir.exists():
|
if key_dir.exists():
|
||||||
shutil.rmtree(key_dir)
|
shutil.rmtree(key_dir)
|
||||||
return True
|
return True
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user