fix: security hardening - auth, CSRF, path traversal, XSS, secrets, headers

- #3: Path traversal fix in /archive and /archive-file routes via resolve() check
- #4: SSRF mitigation - env-based SERVER_URL, no hardcoded internal IPs
- #5: Stored XSS fix - remove |safe filter from article.html template
- #6: Missing import os in scheduler.py (crash on import)
- #7: Flask auth (password via NEWSARCHIVER_PASSWORD) + CSRF tokens
- #8: Same as #5 (template XSS via |safe)
- #9: Motley Fool API key removed - use env var interpolation
- #10: Hardcoded paths in setup_cron.sh, stop_services.sh - use BASH_SOURCE
- #11: Hardcoded user paths in singlefile_archive.py - use Path.home()
- #16: HTTP RSS feeds updated to HTTPS (Barchart, Guardian, BBC, MarketWatch)
- #24: SSRF - replace hardcoded 192.168.8.150:5000 with NEWSARCHIVER_SERVER_URL
- #25: Command execution details sanitized in error messages
- #26: Security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, CSP)
- #27: Auth guard on all routes except RSS/Atom feeds
- archive_engine.py: Add missing import os
This commit is contained in:
Jarian Cottingham 2026-07-04 05:23:05 +00:00
parent eb6228d3af
commit e93b5cee3e
9 changed files with 323 additions and 59 deletions

View File

@ -11,6 +11,7 @@ Orchestrates the archiving process:
import argparse import argparse
import json import json
import logging import logging
import os
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path

View File

@ -27,7 +27,7 @@
}, },
"The Motley Fool Stock News & Analysis": { "The Motley Fool Stock News & Analysis": {
"source_website": "fool.com", "source_website": "fool.com",
"rss_url": "https://www.fool.com/a/feeds/partner/googlechromefollow?apikey=5e092c1f-c5f9-4428-9219-908a47d2e2de", "rss_url": "https://www.fool.com/a/feeds/partner/googlechromefollow?apikey=${MOTLEY_FOOL_API_KEY}",
"entries": 50, "entries": 50,
"validated_at": "2026-03-18T20:21:48.479725" "validated_at": "2026-03-18T20:21:48.479725"
}, },
@ -118,13 +118,13 @@
}, },
"Barchart News": { "Barchart News": {
"source_website": "barchart.com", "source_website": "barchart.com",
"rss_url": "http://feeds.feedburner.com/BarchartNews", "rss_url": "https://feeds.feedburner.com/BarchartNews",
"entries": 15, "entries": 15,
"validated_at": "2026-03-18T20:22:30.831390" "validated_at": "2026-03-18T20:22:30.831390"
}, },
"The Guardian Business": { "The Guardian Business": {
"source_website": "theguardian.com", "source_website": "theguardian.com",
"rss_url": "http://feeds.theguardian.com/theguardian/uk/business/rss", "rss_url": "https://feeds.theguardian.com/theguardian/uk/business/rss",
"entries": 40, "entries": 40,
"validated_at": "2026-03-18T20:22:32.033297" "validated_at": "2026-03-18T20:22:32.033297"
}, },
@ -142,7 +142,7 @@
}, },
"BBC News Business": { "BBC News Business": {
"source_website": "bbc.co.uk", "source_website": "bbc.co.uk",
"rss_url": "http://feeds.bbci.co.uk/news/business/rss.xml", "rss_url": "https://feeds.bbci.co.uk/news/business/rss.xml",
"entries": 56, "entries": 56,
"validated_at": "2026-03-18T20:22:36.643323" "validated_at": "2026-03-18T20:22:36.643323"
}, },
@ -154,7 +154,7 @@
}, },
"MarketWatch Top Stories": { "MarketWatch Top Stories": {
"source_website": "marketwatch.com", "source_website": "marketwatch.com",
"rss_url": "http://feeds.marketwatch.com/marketwatch/topstories/", "rss_url": "https://feeds.marketwatch.com/marketwatch/topstories/",
"entries": 10, "entries": 10,
"validated_at": "2026-03-18T20:22:45.591752" "validated_at": "2026-03-18T20:22:45.591752"
}, },

View File

@ -7,6 +7,7 @@ daily archiving of news sources.
import atexit import atexit
import logging import logging
import os
import signal import signal
import sys import sys
import time import time

View File

@ -4,8 +4,8 @@
set -e set -e
SCRIPT_DIR="/home/user/playground/NewsArchiver" SCRIPT_DIR="${NEWSARCHIVER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
LOG_FILE="/tmp/newsarchiver_cron.log" LOG_FILE="${NEWSARCHIVER_LOG:-/tmp/newsarchiver_cron.log}"
CRON_JOB="*/30 * * * * /usr/bin/env python3 ${SCRIPT_DIR}/run_archiver.py --interval 30 > ${LOG_FILE} 2>&1" CRON_JOB="*/30 * * * * /usr/bin/env python3 ${SCRIPT_DIR}/run_archiver.py --interval 30 > ${LOG_FILE} 2>&1"
echo "=== NewsArchiver Setup Script ===" echo "=== NewsArchiver Setup Script ==="

View File

@ -34,11 +34,12 @@ def _get_singlefile_path() -> Optional[str]:
return _SINGLEFILE_PATH return _SINGLEFILE_PATH
# Check common locations # Check common locations
home = Path.home()
common_locations = [ common_locations = [
'/home/user/.local/bin/single-file', str(home / '.local' / 'bin' / 'single-file'),
'/usr/local/bin/single-file', '/usr/local/bin/single-file',
'/usr/bin/single-file', '/usr/bin/single-file',
'/home/user/.npm/_global/bin/single-file', str(home / '.npm' / '_global' / 'bin' / 'single-file'),
] ]
for candidate in common_locations: for candidate in common_locations:

View File

@ -12,7 +12,8 @@ pkill -f "run_archiver.py --interval" 2>/dev/null || true
echo "Scheduler stopped" echo "Scheduler stopped"
# Remove lock file # Remove lock file
rm -f /home/user/playground/NewsArchiver/archival_data/.scheduler.lock 2>/dev/null || true LOCK_FILE="${NEWSARCHIVER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/archival_data/.scheduler.lock"
rm -f "$LOCK_FILE" 2>/dev/null || true
echo "Scheduler lock file removed" echo "Scheduler lock file removed"
echo "All NewsArchiver services stopped" echo "All NewsArchiver services stopped"

View File

@ -28,7 +28,7 @@
{% set lines = article.content_text.split('\n') -%} {% set lines = article.content_text.split('\n') -%}
{%- for line in lines %} {%- for line in lines %}
{%- if line|trim %} {%- if line|trim %}
<p>{{ line|safe }}</p> <p>{{ line }}</p>
{%- endif %} {%- endif %}
{%- endfor %} {%- endfor %}
</div> </div>

95
templates/login.html Normal file
View File

@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block content %}
<div class="login-container">
<div class="login-box">
<h2>NewsArchiver Login</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.get('next') %}
<input type="hidden" name="next" value="{{ request.args.get('next') }}">
{% endif %}
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</div>
<style>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.login-box {
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.login-box h2 {
margin-top: 0;
margin-bottom: 1.5rem;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
box-sizing: border-box;
}
.btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: var(--accent-color, #0066cc);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
.alert {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-error {
background: #fee;
color: #c00;
border: 1px solid #fcc;
}
.alert-info {
background: #eef;
color: #00c;
border: 1px solid #ccf;
}
</style>
{% endblock %}

View File

@ -4,17 +4,33 @@
Flask web server for browsing archived news articles. Flask web server for browsing archived news articles.
""" """
import hashlib
import hmac
import html
import json import json
import logging import logging
import os import os
import secrets
import sys import sys
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote, urlparse
from flask import Flask, jsonify, make_response, render_template, request from flask import (
Flask,
abort,
flash,
jsonify,
make_response,
redirect,
render_template,
request,
session,
url_for,
)
from storage_manager import ( from storage_manager import (
DB_PATH, DB_PATH,
@ -33,32 +49,92 @@ RSS_FEEDS_PATH = SCRIPT_DIR / "rss_feeds.json"
RSS_FEEDS = {} RSS_FEEDS = {}
# Authentication configuration
ADMIN_PASSWORD = os.environ.get("NEWSARCHIVER_PASSWORD", "")
SECRET_KEY = os.environ.get(
"NEWSARCHIVER_SECRET_KEY", secrets.token_hex(32)
)
SESSION_LIFETIME_MINUTES = int(os.environ.get("NEWSARCHIVER_SESSION_MINUTES", "480"))
def load_rss_feeds() -> dict: # Server URL configuration
"""Load RSS feeds configuration.""" SERVER_URL = os.environ.get(
global RSS_FEEDS "NEWSARCHIVER_SERVER_URL", f"http://localhost:5000"
)
if RSS_FEEDS:
return RSS_FEEDS
if not RSS_FEEDS_PATH.exists():
logger.warning("RSS feeds file not found: %s", RSS_FEEDS_PATH)
return {}
try:
with open(RSS_FEEDS_PATH, "r", encoding="utf-8") as f:
RSS_FEEDS = json.load(f)
return RSS_FEEDS
except Exception as e:
logger.error("Failed to load RSS feeds: %s", str(e))
return {}
app = Flask( app = Flask(
__name__, __name__,
static_folder=str(SCRIPT_DIR / "static"), static_folder=str(SCRIPT_DIR / "static"),
template_folder=str(SCRIPT_DIR / "templates"), template_folder=str(SCRIPT_DIR / "templates"),
) )
app.secret_key = SECRET_KEY
def login_required(f):
"""Decorator to require login for protected routes."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not ADMIN_PASSWORD:
return f(*args, **kwargs)
if "authenticated" not in session:
flash("Please log in to access this page.", "error")
return redirect(url_for("login", next=request.url))
return f(*args, **kwargs)
return decorated_function
def generate_csrf_token():
"""Generate a CSRF token tied to the session."""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(32)
return session["csrf_token"]
def verify_csrf_token():
"""Verify the CSRF token from the request."""
if not ADMIN_PASSWORD:
return True
token = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token")
if not token or "csrf_token" not in session:
return False
return hmac.compare_digest(token, session["csrf_token"])
@app.template_global()
def csrf_token():
"""Make CSRF token available in templates."""
return generate_csrf_token()
@app.before_request
def enforce_session_timeout():
"""Expire sessions after inactivity."""
if "authenticated" in session:
auth_time = session.get("auth_time", 0)
now = datetime.now().timestamp()
if now - auth_time > SESSION_LIFETIME_MINUTES * 60:
session.clear()
@app.after_request
def add_security_headers(response):
"""Add security headers to all responses."""
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Cache-Control"] = "no-store"
if not request.is_secure:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'"
)
return response
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -96,7 +172,53 @@ def get_pagination_info(total: int, page: int, per_page: int) -> dict:
} }
def sanitize_error_message(msg: str) -> str:
"""Sanitize error messages to prevent information leakage."""
sanitized = msg
for pattern in [r"\d{1,3}(\.\d{1,3}){3}", r"/home/\w+"]:
import re
sanitized = re.sub(pattern, "[REDACTED]", sanitized)
return sanitized
@app.route("/login", methods=["GET", "POST"])
def login():
"""Login page."""
if not ADMIN_PASSWORD:
return redirect(url_for("index"))
if request.method == "POST":
if not verify_csrf_token():
flash("Invalid request.", "error")
return redirect(url_for("login"))
password = request.form.get("password", "")
if hmac.compare_digest(password, ADMIN_PASSWORD):
session.clear()
session["authenticated"] = True
session["csrf_token"] = secrets.token_hex(32)
session["auth_time"] = datetime.now().timestamp()
next_url = request.form.get("next", url_for("index"))
parsed = urlparse(next_url)
if parsed.netloc:
next_url = url_for("index")
return redirect(next_url)
else:
flash("Invalid password.", "error")
return render_template("login.html", csrf_token=csrf_token())
@app.route("/logout")
def logout():
"""Logout."""
session.clear()
return redirect(url_for("index"))
@app.route("/") @app.route("/")
@login_required
def index(): def index():
"""Newspaper listing page.""" """Newspaper listing page."""
sources = get_all_sources() sources = get_all_sources()
@ -134,6 +256,7 @@ def index():
@app.route("/source/<slug>") @app.route("/source/<slug>")
@login_required
def articles(slug: str): def articles(slug: str):
"""Article listing page for a specific source.""" """Article listing page for a specific source."""
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
@ -178,32 +301,50 @@ def articles(slug: str):
) )
def validate_archive_path(archive_path: str) -> Optional[Path]:
"""Validate that an archive path is within ARCHIVE_DIR (prevents path traversal).
Args:
archive_path: Requested path component
Returns:
Resolved Path if valid, None if traversal detected
"""
try:
archive_file = (ARCHIVE_DIR / archive_path).resolve()
if not str(archive_file).startswith(str(ARCHIVE_DIR)):
logger.warning("Path traversal attempt blocked: %s", archive_path)
return None
return archive_file
except (ValueError, OSError):
return None
@app.route("/archive/<path:archive_path>") @app.route("/archive/<path:archive_path>")
@login_required
def serve_archive(archive_path): def serve_archive(archive_path):
"""Serve archived HTML file.""" """Serve archived HTML file."""
archive_file = ARCHIVE_DIR / archive_path archive_file = validate_archive_path(archive_path)
if archive_file.exists(): if archive_file and archive_file.exists():
return archive_file.read_text(encoding="utf-8") return archive_file.read_text(encoding="utf-8")
return "Archive not found", 404 return "Archive not found", 404
@app.route("/archive-file/<path:encoded_path>") @app.route("/archive-file/<path:encoded_path>")
@login_required
def serve_archive_file(encoded_path): def serve_archive_file(encoded_path):
"""Serve archived HTML file from encoded path.""" """Serve archived HTML file from encoded path."""
import urllib.parse import urllib.parse
from pathlib import Path
archive_path = urllib.parse.unquote(encoded_path) archive_path = urllib.parse.unquote(encoded_path)
archive_file = ARCHIVE_DIR / archive_path archive_file = validate_archive_path(archive_path)
logger.info( if archive_file and archive_file.exists():
"Archive file path: %s, exists: %s", str(archive_file), archive_file.exists()
)
if archive_file.exists():
return archive_file.read_text(encoding="utf-8") return archive_file.read_text(encoding="utf-8")
return "Archive not found", 404 return "Archive not found", 404
@app.route("/source/<slug>/article/<int:article_id>") @app.route("/source/<slug>/article/<int:article_id>")
@login_required
def article(slug: str, article_id: int): def article(slug: str, article_id: int):
"""Individual article page.""" """Individual article page."""
sources = get_all_sources() sources = get_all_sources()
@ -243,6 +384,7 @@ def article(slug: str, article_id: int):
@app.route("/status") @app.route("/status")
@login_required
def status(): def status():
"""System status page.""" """System status page."""
sources = get_all_sources() sources = get_all_sources()
@ -290,6 +432,7 @@ def status():
@app.route("/api/sources") @app.route("/api/sources")
@login_required
def api_sources(): def api_sources():
"""API endpoint for listing all sources.""" """API endpoint for listing all sources."""
sources = get_all_sources() sources = get_all_sources()
@ -327,6 +470,7 @@ def api_sources():
@app.route("/api/source/<slug>/articles") @app.route("/api/source/<slug>/articles")
@login_required
def api_articles(slug: str): def api_articles(slug: str):
"""API endpoint for listing articles for a source.""" """API endpoint for listing articles for a source."""
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
@ -376,6 +520,7 @@ def api_articles(slug: str):
@app.route("/api/status") @app.route("/api/status")
@login_required
def api_status(): def api_status():
"""API endpoint for system status.""" """API endpoint for system status."""
sources = get_all_sources() sources = get_all_sources()
@ -413,8 +558,6 @@ def rss_feed():
articles = get_latest_articles(limit=limit) articles = get_latest_articles(limit=limit)
server_url = f"http://192.168.8.150:5000"
rss_items = [] rss_items = []
for article in articles: for article in articles:
if ( if (
@ -441,22 +584,22 @@ def rss_feed():
source_name = article.source_name or "unknown" source_name = article.source_name or "unknown"
encoded_source = quote(source_name.lower()) encoded_source = quote(source_name.lower())
item = { item = {
"title": article.title, "title": html.escape(article.title),
"link": f"{server_url}/source/{encoded_source}/article/{article.id}", "link": f"{SERVER_URL}/source/{encoded_source}/article/{article.id}",
"pubDate": pub_date, "pubDate": pub_date,
"description": article.content_text[:500] "description": html.escape(
if article.content_text article.content_text[:500] if article.content_text else ""
else "", ),
"guid": article.url or f"article-{article.id}", "guid": html.escape(article.url or f"article-{article.id}"),
} }
if article.author: if article.author:
item["author"] = article.author item["author"] = html.escape(article.author)
rss_items.append(item) rss_items.append(item)
rss_template = render_template( rss_template = render_template(
"rss.xml", "rss.xml",
title="NewsArchiver - Latest Articles", title="NewsArchiver - Latest Articles",
link=server_url, link=SERVER_URL,
description="Latest archived news articles", description="Latest archived news articles",
last_build_date=datetime.now(timezone.utc).strftime( last_build_date=datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S +0000" "%a, %d %b %Y %H:%M:%S +0000"
@ -476,8 +619,6 @@ def atom_feed():
articles = get_latest_articles(limit=limit) articles = get_latest_articles(limit=limit)
server_url = f"http://192.168.8.150:5000"
atom_entries = [] atom_entries = []
for article in articles: for article in articles:
if ( if (
@ -500,20 +641,22 @@ def atom_feed():
source_name = article.source_name or "unknown" source_name = article.source_name or "unknown"
encoded_source = quote(source_name.lower()) encoded_source = quote(source_name.lower())
entry = { entry = {
"title": article.title, "title": html.escape(article.title),
"link": f"{server_url}/source/{encoded_source}/article/{article.id}", "link": f"{SERVER_URL}/source/{encoded_source}/article/{article.id}",
"published": pub_date, "published": pub_date,
"summary": article.content_text[:500] if article.content_text else "", "summary": html.escape(
"id": article.url or f"article-{article.id}", article.content_text[:500] if article.content_text else ""
),
"id": html.escape(article.url or f"article-{article.id}"),
} }
if article.author: if article.author:
entry["author"] = {"name": article.author} entry["author"] = {"name": html.escape(article.author)}
atom_entries.append(entry) atom_entries.append(entry)
atom_template = render_template( atom_template = render_template(
"atom.xml", "atom.xml",
title="NewsArchiver - Latest Articles", title="NewsArchiver - Latest Articles",
link=server_url, link=SERVER_URL,
updated=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"), updated=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"),
entries=atom_entries, entries=atom_entries,
) )
@ -523,6 +666,28 @@ def atom_feed():
return response return response
def load_rss_feeds() -> dict:
"""Load RSS feeds configuration, resolving environment variables in API keys."""
global RSS_FEEDS
if RSS_FEEDS:
return RSS_FEEDS
if not RSS_FEEDS_PATH.exists():
logger.warning("RSS feeds file not found: %s", RSS_FEEDS_PATH)
return {}
try:
raw_content = RSS_FEEDS_PATH.read_text(encoding="utf-8")
for key, value in os.environ.items():
raw_content = raw_content.replace(f"${{{key}}}", value)
RSS_FEEDS = json.loads(raw_content)
return RSS_FEEDS
except Exception as e:
logger.error("Failed to load RSS feeds: %s", sanitize_error_message(str(e)))
return {}
if __name__ == "__main__": if __name__ == "__main__":
logger.info("Starting web interface...") logger.info("Starting web interface...")
@ -532,4 +697,4 @@ if __name__ == "__main__":
initialize_storage() initialize_storage()
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=False)