forked from ComputerTech/bastebin
Implement user deletion tokens, admin panel, and security hardening
This commit is contained in:
parent
cdbd700c2e
commit
8365b38673
|
|
@ -61,4 +61,4 @@ Thumbs.db
|
|||
*.temp
|
||||
|
||||
# User uploads (if you add file upload functionality)
|
||||
uploads/
|
||||
uploads/gunicorn.pid
|
||||
|
|
|
|||
169
app.py
169
app.py
|
|
@ -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,24 +254,9 @@ 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:
|
||||
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()
|
||||
# 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
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1 +1 @@
|
|||
26810
|
||||
32002
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue