Harden backup security: password confirmation, CSRF, env toggle

Address security review feedback from jbtronics:

- Add IS_AUTHENTICATED_FULLY to all sensitive endpoints (create/delete
  backup, delete log, download backup, start update, restore)
- Change backup download from GET to POST with CSRF token
- Require password confirmation before downloading backups (backups
  contain sensitive data like password hashes and secrets)
- Add DISABLE_BACKUP_DOWNLOAD env var (default: disabled) to control
  whether backup downloads are allowed
- Add password confirmation modal with security warning in template
- Add comprehensive tests: auth checks, env var blocking, POST-only
  enforcement, status/progress endpoint auth
This commit is contained in:
Sebastian Almberg 2026-03-05 19:06:54 +01:00
parent c16b6c7fac
commit dd8698840d
5 changed files with 257 additions and 43 deletions

View file

@ -416,12 +416,15 @@
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if is_granted('@system.manage_updates') %}
<a href="{{ path('admin_update_manager_backup_download', {filename: backup.file}) }}"
class="btn btn-outline-secondary"
title="{% trans %}update_manager.backup.download{% endtrans %}">
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
<button type="button"
class="btn btn-outline-secondary btn-download-backup"
data-filename="{{ backup.file }}"
data-bs-toggle="modal"
data-bs-target="#downloadBackupModal"
title="{% trans %}update_manager.backup.download{% endtrans %}">
<i class="fas fa-download"></i>
</a>
</button>
{% endif %}
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
@ -473,4 +476,53 @@
</div>
</div>
</div>
{# Password confirmation modal for backup download #}
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
<div class="modal fade" id="downloadBackupModal" tabindex="-1" aria-labelledby="downloadBackupModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('admin_update_manager_backup_download') }}" method="post" id="downloadBackupForm">
<div class="modal-header">
<h5 class="modal-title" id="downloadBackupModalLabel">
<i class="fas fa-download me-2"></i>{% trans %}update_manager.backup.download{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-1"></i>
{% trans %}update_manager.backup.download.security_warning{% endtrans %}
</div>
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_download') }}">
<input type="hidden" name="filename" id="downloadBackupFilename" value="">
<div class="mb-3">
<label for="downloadBackupPassword" class="form-label">
{% trans %}update_manager.backup.download.password_label{% endtrans %}
</label>
<input type="password" class="form-control" id="downloadBackupPassword"
name="password" required autocomplete="current-password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans %}cancel{% endtrans %}
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-download me-1"></i>{% trans %}update_manager.backup.download{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
<script nonce="{{ csp_nonce('script') }}">
document.getElementById('downloadBackupModal').addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
var filename = button.getAttribute('data-filename');
document.getElementById('downloadBackupFilename').value = filename;
document.getElementById('downloadBackupPassword').value = '';
});
</script>
{% endif %}
{% endblock %}