improving safety, Sanitizing input, Validating and sanitizing data, Ensuring proper HTML escaping

This commit is contained in:
Bernd 2025-07-23 20:42:35 +05:00
parent bee297bf19
commit df9085f479
4 changed files with 79 additions and 14 deletions

BIN
anime_backlog.db-shm Normal file

Binary file not shown.

BIN
anime_backlog.db-wal Normal file

Binary file not shown.

View File

@ -1,6 +1,7 @@
import sqlite3 import sqlite3
import csv import csv
import logging import logging
import html
# Set up logging # Set up logging
logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR, logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR,
@ -8,7 +9,8 @@ logging.basicConfig(filename='anime_tracker.log', level=logging.ERROR,
class AnimeBackend: class AnimeBackend:
def __init__(self): def __init__(self):
self.db = sqlite3.connect('anime_backlog.db') self.db = sqlite3.connect('anime_backlog.db', isolation_level=None) # Autocommit mode to prevent locks
self.db.execute('PRAGMA journal_mode=WAL') # Use WAL mode for better concurrency
self.create_table() self.create_table()
def create_table(self): def create_table(self):
@ -68,25 +70,64 @@ class AnimeBackend:
def add_anime(self, data): def add_anime(self, data):
try: try:
# Sanitize string inputs
sanitized_data = {
'name': html.escape(data['name'].strip()) if data['name'] else '',
'year': data['year'],
'season': data['season'].strip() if data['season'] else '',
'status': data['status'].strip() if data['status'] else 'unwatched',
'type': data['type'].strip() if data['type'] else '',
'comment': html.escape(data['comment'].strip()) if data['comment'] else '',
'url': data['url'].strip() if data['url'] else ''
}
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute( cursor.execute(
"INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO anime (name, year, season, status, type, comment, url) VALUES (?, ?, ?, ?, ?, ?, ?)",
(data['name'], data['year'], data['season'], data['status'], data['type'], data['comment'], data['url']) (
sanitized_data['name'],
sanitized_data['year'],
sanitized_data['season'],
sanitized_data['status'],
sanitized_data['type'],
sanitized_data['comment'],
sanitized_data['url']
)
) )
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error adding anime: {e}") logging.error(f"Error adding anime: {e}")
self.db.rollback()
def edit_anime(self, anime_id, data): def edit_anime(self, anime_id, data):
try: try:
# Sanitize string inputs
sanitized_data = {
'name': html.escape(data['name'].strip()) if data['name'] else '',
'year': data['year'],
'season': data['season'].strip() if data['season'] else '',
'status': data['status'].strip() if data['status'] else 'unwatched',
'type': data['type'].strip() if data['type'] else '',
'comment': html.escape(data['comment'].strip()) if data['comment'] else '',
'url': data['url'].strip() if data['url'] else ''
}
cursor = self.db.cursor() cursor = self.db.cursor()
cursor.execute( cursor.execute(
"UPDATE anime SET name=?, year=?, season=?, status=?, type=?, comment=?, url=? WHERE id=?", "UPDATE anime SET name=?, year=?, season=?, status=?, type=?, comment=?, url=? WHERE id=?",
(data['name'], data['year'], data['season'], data['status'], data['type'], data['comment'], data['url'], anime_id) (
sanitized_data['name'],
sanitized_data['year'],
sanitized_data['season'],
sanitized_data['status'],
sanitized_data['type'],
sanitized_data['comment'],
sanitized_data['url'],
anime_id
)
) )
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error editing anime id {anime_id}: {e}") logging.error(f"Error editing anime id {anime_id}: {e}")
self.db.rollback()
def delete_anime(self, anime_id): def delete_anime(self, anime_id):
try: try:
@ -95,6 +136,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error deleting anime id {anime_id}: {e}") logging.error(f"Error deleting anime id {anime_id}: {e}")
self.db.rollback()
def change_status(self, anime_id, new_status): def change_status(self, anime_id, new_status):
try: try:
@ -103,6 +145,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error changing status for anime id {anime_id}: {e}") logging.error(f"Error changing status for anime id {anime_id}: {e}")
self.db.rollback()
def add_placeholders_for_year(self, year): def add_placeholders_for_year(self, year):
try: try:
@ -115,6 +158,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error adding placeholders for year {year}: {e}") logging.error(f"Error adding placeholders for year {year}: {e}")
self.db.rollback()
def import_from_csv(self, file_name): def import_from_csv(self, file_name):
try: try:
@ -134,6 +178,13 @@ class AnimeBackend:
year = int(year_str) year = int(year_str)
except ValueError: except ValueError:
continue continue
# Sanitize CSV inputs
name = html.escape(name.strip()) if name else ''
season = season.strip() if season else ''
status = status.strip() if status else 'unwatched'
type_ = type_.strip() if type_ else ''
comment = html.escape(comment.strip()) if comment else ''
url = url.strip() if url else ''
cursor.execute( cursor.execute(
"SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?", "SELECT id FROM anime WHERE name = ? AND year = ? AND season = ?",
(name, year, season) (name, year, season)
@ -146,6 +197,7 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error importing from CSV {file_name}: {e}") logging.error(f"Error importing from CSV {file_name}: {e}")
self.db.rollback()
def export_to_csv(self, file_name): def export_to_csv(self, file_name):
try: try:
@ -153,7 +205,7 @@ class AnimeBackend:
cursor.execute("SELECT * FROM anime") cursor.execute("SELECT * FROM anime")
rows = cursor.fetchall() rows = cursor.fetchall()
with open(file_name, 'w', newline='') as f: with open(file_name, 'w', newline='') as f:
writer = csv.writer(f) writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
writer.writerow(['id', 'name', 'year', 'season', 'status', 'type', 'comment', 'url']) writer.writerow(['id', 'name', 'year', 'season', 'status', 'type', 'comment', 'url'])
writer.writerows(rows) writer.writerows(rows)
except Exception as e: except Exception as e:
@ -166,3 +218,4 @@ class AnimeBackend:
self.db.commit() self.db.commit()
except Exception as e: except Exception as e:
logging.error(f"Error deleting year {year}: {e}") logging.error(f"Error deleting year {year}: {e}")
self.db.rollback()

View File

@ -9,7 +9,7 @@ from PyQt5.QtWidgets import (
QMainWindow, QTabWidget, QScrollArea, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QMainWindow, QTabWidget, QScrollArea, QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox, QLabel, QToolButton, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QSpinBox,
QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox, QComboBox, QTextEdit, QDialogButtonBox, QAction, QFileDialog, QMessageBox,
QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView, QPushButton QInputDialog, QApplication, QAbstractItemView, QSizePolicy, QHeaderView
) )
from PyQt5.QtCore import Qt, QSettings from PyQt5.QtCore import Qt, QSettings
from PyQt5.QtGui import QColor, QIcon, QFont from PyQt5.QtGui import QColor, QIcon, QFont
@ -25,6 +25,7 @@ class AnimeDialog(QDialog):
self.setWindowTitle("Add Anime" if entry is None else "Edit Anime") self.setWindowTitle("Add Anime" if entry is None else "Edit Anime")
layout = QFormLayout(self) layout = QFormLayout(self)
self.name_edit = QLineEdit() self.name_edit = QLineEdit()
self.name_edit.setMaxLength(255) # Prevent overly long inputs
layout.addRow("Name", self.name_edit) layout.addRow("Name", self.name_edit)
self.year_spin = QSpinBox() self.year_spin = QSpinBox()
self.year_spin.setRange(1900, 2100) self.year_spin.setRange(1900, 2100)
@ -39,20 +40,23 @@ class AnimeDialog(QDialog):
self.status_combo.addItems(['unwatched', 'watching', 'completed']) self.status_combo.addItems(['unwatched', 'watching', 'completed'])
layout.addRow("Status", self.status_combo) layout.addRow("Status", self.status_combo)
self.comment_edit = QTextEdit() self.comment_edit = QTextEdit()
self.comment_edit.setAcceptRichText(False) # Prevent HTML injection
layout.addRow("Comment", self.comment_edit) layout.addRow("Comment", self.comment_edit)
self.url_edit = QLineEdit() self.url_edit = QLineEdit()
self.url_edit.setMaxLength(2048) # Reasonable limit for URLs
layout.addRow("MAL URL", self.url_edit) layout.addRow("MAL URL", self.url_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addRow(buttons) layout.addRow(buttons)
if entry: if entry:
self.name_edit.setText(entry[1]) # Unescape for display in input fields
self.name_edit.setText(html.unescape(entry[1]))
self.year_spin.setValue(entry[2]) self.year_spin.setValue(entry[2])
self.season_combo.setCurrentText(entry[3]) self.season_combo.setCurrentText(entry[3])
self.status_combo.setCurrentText(entry[4]) self.status_combo.setCurrentText(entry[4])
self.type_combo.setCurrentText(entry[5] or '') self.type_combo.setCurrentText(entry[5] or '')
self.comment_edit.setText(entry[6] or '') self.comment_edit.setPlainText(html.unescape(entry[6] or ''))
self.url_edit.setText(entry[7] or '') self.url_edit.setText(entry[7] or '')
else: else:
if default_year is not None: if default_year is not None:
@ -70,14 +74,15 @@ class AnimeDialog(QDialog):
self.season_combo.setEnabled(True) self.season_combo.setEnabled(True)
def get_data(self): def get_data(self):
# Sanitize inputs by escaping special characters
return { return {
'name': self.name_edit.text(), 'name': html.escape(self.name_edit.text().strip()),
'year': self.year_spin.value(), 'year': self.year_spin.value(),
'season': self.season_combo.currentText(), 'season': html.escape(self.season_combo.currentText().strip()),
'status': self.status_combo.currentText(), 'status': html.escape(self.status_combo.currentText().strip()),
'type': self.type_combo.currentText(), 'type': html.escape(self.type_combo.currentText().strip()),
'comment': self.comment_edit.toPlainText(), 'comment': html.escape(self.comment_edit.toPlainText().strip()),
'url': self.url_edit.text() 'url': html.escape(self.url_edit.text().strip())
} }
class ShortcutsDialog(QDialog): class ShortcutsDialog(QDialog):
@ -514,6 +519,7 @@ class AnimeTracker(QMainWindow):
index = self.tab_widget.addTab(year_tab, tab_text) index = self.tab_widget.addTab(year_tab, tab_text)
if completed: if completed:
self.tab_widget.tabBar().setTabTextColor(index, QColor('gray')) self.tab_widget.tabBar().setTabTextColor(index, QColor('gray'))
self.filter_tables(self.search_text) # Apply search filter after loading tabs
def create_actions_widget(self, anime_id, status): def create_actions_widget(self, anime_id, status):
widget = QWidget() widget = QWidget()
@ -572,6 +578,9 @@ class AnimeTracker(QMainWindow):
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
current_id = self.get_current_tab_identifier() current_id = self.get_current_tab_identifier()
data = dialog.get_data() data = dialog.get_data()
if not data['name']:
QMessageBox.warning(self, "Error", "Anime name cannot be empty.")
return
self.backend.add_anime(data) self.backend.add_anime(data)
self.load_tabs() self.load_tabs()
self.set_current_tab_by_identifier(current_id) self.set_current_tab_by_identifier(current_id)
@ -583,6 +592,9 @@ class AnimeTracker(QMainWindow):
dialog = AnimeDialog(self, entry) dialog = AnimeDialog(self, entry)
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
data = dialog.get_data() data = dialog.get_data()
if not data['name']:
QMessageBox.warning(self, "Error", "Anime name cannot be empty.")
return
self.backend.edit_anime(anime_id, data) self.backend.edit_anime(anime_id, data)
self.load_tabs() self.load_tabs()
self.set_current_tab_by_identifier(current_id) self.set_current_tab_by_identifier(current_id)