Implement user deletion tokens, admin panel, and security hardening

This commit is contained in:
ComputerTech 2026-03-31 12:05:06 +01:00
parent cdbd700c2e
commit 8365b38673
12 changed files with 365 additions and 66 deletions

2
.gitignore vendored
View File

@ -61,4 +61,4 @@ Thumbs.db
*.temp
# User uploads (if you add file upload functionality)
uploads/
uploads/gunicorn.pid

167
app.py
View File

@ -8,7 +8,8 @@ import threading
import time
import uuid
import datetime
from flask import Flask, render_template, request, jsonify, abort, Response, g
from flask import Flask, render_template, request, jsonify, abort, Response, g, session, redirect, url_for
from werkzeug.security import check_password_hash
_UTC = datetime.timezone.utc
@ -49,6 +50,10 @@ if _secret_key in _DEFAULT_KEYS:
'or in config.json before starting the server.'
)
app.config['SECRET_KEY'] = _secret_key
# ── Session hardening ─────────────────────────────────────────────────────────
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SECURE'] = _server.get('use_https', False)
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
if _server.get('debug', False):
print('WARNING: debug=true is set in config.json — never use debug mode in production!',
@ -126,9 +131,13 @@ def init_db():
encrypted_data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
views INTEGER DEFAULT 0
views INTEGER DEFAULT 0,
deletion_token TEXT
)
''')
# Migration: add deletion_token column to existing table if missing
if columns and 'id' in columns and 'deletion_token' not in columns:
cursor.execute('ALTER TABLE pastes ADD COLUMN deletion_token TEXT')
cursor.execute('''
CREATE TABLE IF NOT EXISTS rate_limits (
ip_address TEXT,
@ -188,6 +197,37 @@ def validate_encrypted_data(value):
return False
return bool(_ENCRYPTED_RE.match(value))
def _is_same_origin():
"""Verify that the request is from the same origin to prevent CSRF."""
origin = request.headers.get('Origin')
referer = request.headers.get('Referer')
base_url = request.host_url.rstrip('/') # e.g. http://localhost:5500
if origin:
return origin.rstrip('/') == base_url
if referer:
return referer.startswith(base_url)
return False
def _check_rate_limit(remote_ip, key_prefix='rl', window=600, limit=10):
"""Generic rate limiting via SQLite. Window is in seconds."""
now_ts = time.time()
conn = get_db_connection()
try:
count = conn.execute(
'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?',
(f"{key_prefix}:{remote_ip}", now_ts - window)
).fetchone()[0]
if count >= limit:
return False
conn.execute('INSERT INTO rate_limits (ip_address, timestamp) VALUES (?, ?)', (f"{key_prefix}:{remote_ip}", now_ts))
conn.commit()
return True
finally:
conn.close()
def _get_paste_or_abort(paste_id):
conn = get_db_connection()
try:
@ -214,25 +254,10 @@ def index():
def create_paste():
# ── Rate limiting ────────────────────────────────────────────────────────
# 10 pastes per 10 minutes per IP address (SQLite backed for worker safety)
remote_ip = request.remote_addr
now_ts = time.time()
window = 600 # 10 minutes
conn = get_db_connection()
try:
# Purge old for this IP specifically or just check current
count = conn.execute(
'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?',
(remote_ip, now_ts - window)
).fetchone()[0]
if count >= 10:
# 10 pastes per 10 minutes per IP address (SQLite backed for worker safety)
if not _check_rate_limit(request.remote_addr, key_prefix='create', limit=10):
return jsonify({'error': 'Rate limit exceeded. Please wait a few minutes.'}), 429
conn.execute('INSERT INTO rate_limits (ip_address, timestamp) VALUES (?, ?)', (remote_ip, now_ts))
conn.commit()
finally:
conn.close()
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'JSON body required'}), 400
@ -283,6 +308,7 @@ def create_paste():
else:
expires_at = datetime.datetime.now(_UTC) + delta
deletion_token = secrets.token_urlsafe(32)
paste_id = None
conn = get_db_connection()
try:
@ -290,8 +316,8 @@ def create_paste():
paste_id = generate_paste_id()
try:
conn.execute(
'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)',
(paste_id, store_data, expires_at)
'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token) VALUES (?, ?, ?, ?)',
(paste_id, store_data, expires_at, deletion_token)
)
conn.commit()
break
@ -301,7 +327,39 @@ def create_paste():
return jsonify({'error': 'Service temporarily unavailable'}), 503
finally:
conn.close()
return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'})
return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}', 'deletion_token': deletion_token})
@app.route('/api/delete/<string:paste_id>', methods=['POST'])
def delete_paste(paste_id):
if not _PASTE_ID_RE.match(paste_id):
abort(404)
# CSRF check
if not _is_same_origin():
return jsonify({'error': 'Cross-Origin request blocked'}), 403
data = request.get_json(silent=True) or {}
token = data.get('token')
if not token:
return jsonify({'error': 'Deletion token required'}), 400
conn = get_db_connection()
try:
paste = conn.execute('SELECT deletion_token FROM pastes WHERE id = ?', (paste_id,)).fetchone()
if not paste:
return jsonify({'error': 'Paste not found'}), 404
# Verify token
if paste['deletion_token'] != token:
return jsonify({'error': 'Invalid deletion token'}), 403
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
conn.commit()
finally:
conn.close()
return jsonify({'success': True, 'message': 'Paste deleted successfully'})
@app.route('/<string:paste_id>')
def view_paste(paste_id):
@ -415,6 +473,71 @@ def recent_pastes():
conn.close()
return render_template('recent.html', pastes=pastes)
# ── Admin Panel ───────────────────────────────────────────────────────────────
def is_admin():
return session.get('admin_logged_in') == True
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
error = None
admin_cfg = CFG.get('admin', {})
if request.method == 'POST':
# 1. Rate limit
if not _check_rate_limit(request.remote_addr, key_prefix='admin_login', window=3600, limit=5):
return render_template('admin_login.html', error='Too many login attempts. Try again later.'), 429
# 2. CSRF
if not _is_same_origin():
return "CSRF Blocked", 403
user = request.form.get('username')
pw = request.form.get('password')
stored_hash = admin_cfg.get('pass')
if user == admin_cfg.get('user') and check_password_hash(stored_hash, pw):
session['admin_logged_in'] = True
return redirect(url_for('admin_dashboard'))
else:
time.sleep(1) # Slow down brute force
error = 'Invalid credentials'
return render_template('admin_login.html', error=error)
@app.route('/admin/logout')
def admin_logout():
session.pop('admin_logged_in', None)
return redirect(url_for('index'))
@app.route('/admin')
def admin_dashboard():
if not is_admin():
return redirect(url_for('admin_login'))
now = datetime.datetime.now(_UTC).isoformat()
conn = get_db_connection()
try:
pastes = conn.execute(
'SELECT id, created_at, expires_at, views FROM pastes ORDER BY created_at DESC'
).fetchall()
finally:
conn.close()
return render_template('admin_dashboard.html', pastes=pastes)
@app.route('/admin/delete/<string:paste_id>', methods=['POST'])
def admin_delete_paste(paste_id):
if not is_admin():
abort(403)
if not _is_same_origin():
abort(403)
conn = get_db_connection()
try:
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
conn.commit()
finally:
conn.close()
return redirect(url_for('admin_dashboard'))
# ── Error handlers ────────────────────────────────────────────────────────────
@app.errorhandler(404)

View File

@ -1,40 +0,0 @@
server {
listen 80;
server_name bastebin.com www.bastebin.com;
# Static files serving - offload from Flask/Gunicorn
location /static/ {
alias /home/computertech/bastebin/static/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Proxy all other requests to Gunicorn
location / {
proxy_pass http://127.0.0.1:5500;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts and keeping connections alive
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
# Max payload size (matching 2MB config or slightly above)
client_max_body_size 5M;
# Buffer settings
proxy_buffers 16 16k;
proxy_buffer_size 32k;
}
# Log files - ensure these directories exist or use /var/log/nginx
access_log /var/log/nginx/bastebin.access.log;
error_log /var/log/nginx/bastebin.error.log;
}

View File

@ -132,5 +132,10 @@
{"value": "nginx", "name": "Nginx Config"},
{"value": "toml", "name": "TOML"},
{"value": "ini", "name": "INI / Config"}
]
],
"admin": {
"enabled": true,
"user": "admin",
"pass": "scrypt:32768:8:1$WKz9I6qE4hh0paUQ$6fd34e7f0195280f81301a92f5bac26d247f95d64744cb2c6e44108a3d8420eba5343b7b2ba657f39404f4ef102ce2e62a689e7797a43f3169fd69dca7b5b3c7"
}
}

21
generate_hash.py Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
import getpass
from werkzeug.security import generate_password_hash
def main():
print("--- Bastebin Admin Password Hasher ---")
password = getpass.getpass("Enter the new admin password: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
print("Error: Passwords do not match.")
return
hashed = generate_password_hash(password)
print("\nSuccess! Copy the following hash into your config.json:")
print("-" * 20)
print(hashed)
print("-" * 20)
if __name__ == "__main__":
main()

View File

@ -1 +1 @@
26810
32002

View File

@ -290,3 +290,76 @@ pre[class*="language-"], code[class*="language-"] {
.nav-input { width: 90px; }
.nav-paste-title { display: none; }
}
/* ── Utility & New Actions ─────────────────────────────────────────────── */
.nav-btn-danger {
color: var(--danger);
border-color: var(--danger);
}
.nav-btn-danger:hover {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.nav-user {
font-size: 0.8rem;
color: var(--text-muted);
margin-right: 0.5rem;
}
/* ── Auth Box (Login) ──────────────────────────────────────────────────── */
.auth-box {
max-width: 360px;
margin: 4rem auto;
padding: 2rem;
background: var(--surface);
border: 1px solid var(--nav-border);
border-radius: var(--radius);
text-align: center;
}
.auth-box h1 { font-size: 1.25rem; margin-bottom: 1.5rem; }
.form-group { text-align: left; margin-bottom: 1rem; }
.form-group label { display: block; font-size: 0.8rem; color: var(--text-sub); margin-bottom: 0.3rem; }
.form-group input {
width: 100%; padding: 0.5rem; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg); color: var(--text); outline: none;
}
.form-group input:focus { border-color: var(--primary); }
.submit-btn {
width: 100%; padding: 0.6rem; background: var(--primary); color: #fff;
border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; margin-top: 0.5rem;
}
.submit-btn:hover { background: var(--primary-h); }
.error-msg { color: var(--danger); font-size: 0.8rem; margin-bottom: 1rem; }
/* ── Admin Dashboard ───────────────────────────────────────────────────── */
.admin-header { margin-bottom: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; }
.admin-header h1 { font-size: 1.5rem; }
.admin-header p { color: var(--text-sub); }
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.admin-table th, .admin-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--border);
text-align: left;
}
.admin-table th { color: var(--text-sub); font-weight: 600; }
.admin-table tr:hover { background: var(--surface); }
.btn-delete-small {
background: none;
border: 1px solid var(--danger);
color: var(--danger);
padding: 0.2rem 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.75rem;
}
.btn-delete-small:hover { background: var(--danger); color: #fff; }
.table-responsive { overflow-x: auto; }

View File

@ -113,6 +113,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.clearDraft === 'function') {
window.clearDraft();
}
if (result.deletion_token) {
localStorage.setItem('del_' + result.paste_id, result.deletion_token);
}
window.location.href = result.url + (keyBase64 ? '#' + keyBase64 : '');
}
} catch (err) {

View File

@ -57,6 +57,7 @@ document.addEventListener('DOMContentLoaded', async () => {
renderPaste(_decryptedPaste);
initPasteActions();
initLineNumbers();
initDeletion();
});
function initPasteActions() {
@ -174,3 +175,41 @@ function downloadPaste() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function initDeletion() {
const pasteId = window.location.pathname.split('/').pop();
const token = localStorage.getItem('del_' + pasteId);
const deleteBtn = document.getElementById('deleteBtn');
if (token && deleteBtn) {
deleteBtn.style.display = 'inline-block';
deleteBtn.addEventListener('click', async () => {
if (!confirm('Are you sure you want to delete this paste? This action cannot be undone.')) return;
deleteBtn.disabled = true;
deleteBtn.textContent = '...';
try {
const resp = await fetch('/api/delete/' + pasteId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const result = await resp.json();
if (result.success) {
localStorage.removeItem('del_' + pasteId);
window.location.href = '/';
} else {
alert('Error: ' + (result.error || 'Unknown error'));
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete';
}
} catch (err) {
console.error(err);
alert('Failed to delete paste. Connection error.');
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete';
}
});
}
}

View File

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - {{ cfg.site.name }}{% endblock %}
{% block nav_actions %}
<span class="nav-user">Logged in as Admin</span>
<a href="{{ url_for('admin_logout') }}" class="nav-btn">Logout</a>
<a href="{{ url_for('index') }}" class="nav-btn nav-btn-save">New Paste</a>
{% endblock %}
{% block content %}
<div class="admin-container">
<div class="admin-header">
<h1>Global Paste Management</h1>
<p>Total Pastes: {{ pastes|length }}</p>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Created</th>
<th>Expires</th>
<th>Views</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for paste in pastes %}
<tr>
<td><a href="{{ url_for('view_paste', paste_id=paste.id) }}" target="_blank"><code>{{ paste.id }}</code></a></td>
<td>{{ paste.created_at }}</td>
<td>{{ paste.expires_at or 'Never' }}</td>
<td>{{ paste.views }}</td>
<td>
<form action="{{ url_for('admin_delete_paste', paste_id=paste.id) }}" method="POST" onsubmit="return confirm('Delete this paste permanently?')">
<button type="submit" class="btn-delete-small">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Admin Login - {{ cfg.site.name }}{% endblock %}
{% block nav_actions %}
<a href="{{ url_for('index') }}" class="nav-btn">Back</a>
{% endblock %}
{% block content %}
<div class="auth-box">
<h1>Admin Access</h1>
{% if error %}
<div class="error-msg">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="submit-btn" id="loginBtn">Login</button>
</form>
</div>
{% endblock %}

View File

@ -11,6 +11,7 @@
<button id="rawBtn" class="nav-btn">Raw</button>
<button id="copyBtn" class="nav-btn">Copy</button>
<button id="downloadBtn" class="nav-btn">Download</button>
<button id="deleteBtn" class="nav-btn nav-btn-danger" style="display:none">Delete</button>
<a href="{{ url_for('index') }}" class="nav-btn nav-btn-save">New</a>
{% if cfg.theme.allow_user_toggle %}
<button id="themeToggle" class="theme-toggle">🌙</button>