Files
BanGUI/backend/tests/test_repositories/test_fail2ban_db_repo.py
Lukas 667ab674ca Fix SQLite LIKE wildcard escaping in IP filter queries
- Add escape_like() helper to escape % and _ wildcards in LIKE queries
- Update fail2ban_db_repo.get_history_page() to use escaping
- Update history_archive_repo.get_archived_history() to use escaping
- Add ESCAPE clause to all LIKE queries
- Add comprehensive unit tests for escape_like function
- Add integration tests for LIKE wildcard handling
- Document LIKE escaping best practices in Backend-Development.md

Fixes TASK-017: Prevent unintended LIKE matches when IP filter contains
special characters like underscore or percent sign.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:07:49 +02:00

259 lines
8.1 KiB
Python

"""Tests for the fail2ban_db repository.
These tests use an in-memory sqlite file created under pytest's tmp_path and
exercise the core query functions used by the services.
"""
from pathlib import Path
import aiosqlite
import pytest
from app.repositories import fail2ban_db_repo
async def _create_bans_table(db: aiosqlite.Connection) -> None:
await db.execute(
"""
CREATE TABLE bans (
jail TEXT,
ip TEXT,
timeofban INTEGER,
bancount INTEGER,
data TEXT
)
"""
)
await db.commit()
@pytest.mark.asyncio
async def test_check_db_nonempty_returns_false_when_table_is_empty(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
assert await fail2ban_db_repo.check_db_nonempty(db_path) is False
@pytest.mark.asyncio
async def test_check_db_nonempty_returns_true_when_row_exists(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.execute(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
("jail1", "1.2.3.4", 123, 1, "{}"),
)
await db.commit()
assert await fail2ban_db_repo.check_db_nonempty(db_path) is True
@pytest.mark.asyncio
async def test_get_currently_banned_filters_and_pagination(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
# Three bans; one is from the blocklist-import jail.
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 10, 1, "{}"),
("blocklist-import", "2.2.2.2", 20, 2, "{}"),
("jail1", "3.3.3.3", 30, 3, "{}"),
],
)
await db.commit()
records, total = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=15,
origin="selfblock",
limit=10,
offset=0,
)
# Only the non-blocklist row with timeofban >= 15 should remain.
assert total == 1
assert len(records) == 1
assert records[0].ip == "3.3.3.3"
@pytest.mark.asyncio
async def test_get_currently_banned_filters_by_ip_list(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 10, 1, "{}"),
("jail1", "2.2.2.2", 20, 1, "{}"),
("jail1", "3.3.3.3", 30, 1, "{}"),
],
)
await db.commit()
records, total = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=0,
ip_filter=["2.2.2.2", "3.3.3.3"],
)
assert total == 2
assert len(records) == 2
assert {record.ip for record in records} == {"2.2.2.2", "3.3.3.3"}
@pytest.mark.asyncio
async def test_get_ban_counts_by_bucket_ignores_out_of_range_buckets(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 5, 1, "{}"),
("jail1", "2.2.2.2", 15, 1, "{}"),
("jail1", "3.3.3.3", 35, 1, "{}"),
],
)
await db.commit()
counts = await fail2ban_db_repo.get_ban_counts_by_bucket(
db_path=db_path,
since=0,
bucket_secs=10,
num_buckets=3,
)
assert counts == [1, 1, 0]
@pytest.mark.asyncio
async def test_get_history_page_and_for_ip(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 100, 1, "{}"),
("jail1", "1.1.1.1", 200, 2, "{}"),
("jail1", "2.2.2.2", 300, 3, "{}"),
],
)
await db.commit()
page, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=None,
jail="jail1",
ip_filter="1.1.1",
page=1,
page_size=10,
)
assert total == 2
assert len(page) == 2
assert page[0].ip == "1.1.1.1"
history_for_ip = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip="2.2.2.2")
assert len(history_for_ip) == 1
assert history_for_ip[0].ip == "2.2.2.2"
@pytest.mark.asyncio
async def test_get_history_page_origin_filter(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 100, 1, "{}"),
("blocklist-import", "2.2.2.2", 200, 1, "{}"),
],
)
await db.commit()
page, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=None,
jail=None,
ip_filter=None,
origin="selfblock",
page=1,
page_size=10,
)
assert total == 1
assert len(page) == 1
assert page[0].ip == "1.1.1.1"
@pytest.mark.asyncio
async def test_get_history_page_ip_filter_with_wildcard_like_underscore(tmp_path: Path) -> None:
"""Test that ip_filter with underscore does not trigger LIKE wildcard match."""
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
# Insert IPs: one with dots (should match filter "10.0.0"), others with different patterns
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "10.0.0.1", 100, 1, "{}"),
("jail1", "10.0.0.2", 150, 1, "{}"),
("jail1", "10.0.0_1", 200, 1, "{}"), # This should NOT match "10.0.0_" if unescaped
],
)
await db.commit()
# Use ip_filter that contains underscore character
page, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=None,
jail=None,
ip_filter="10.0.0_", # With underscore, should match only the exact IP
page=1,
page_size=10,
)
# Should only match the IP that starts with exactly "10.0.0_" (one IP)
assert total == 1
assert len(page) == 1
assert page[0].ip == "10.0.0_1"
@pytest.mark.asyncio
async def test_get_history_page_ip_filter_with_wildcard_like_percent(tmp_path: Path) -> None:
"""Test that ip_filter with percent sign does not trigger LIKE wildcard match."""
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "10.0.0.1", 100, 1, "{}"),
("jail1", "10.0.0%test", 200, 1, "{}"), # IP with literal %
],
)
await db.commit()
# Use ip_filter with percent sign - should only match IPs that start with "10.0.0%"
page, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=None,
jail=None,
ip_filter="10.0.0%",
page=1,
page_size=10,
)
# Should only match the IP with the literal % character
assert total == 1
assert len(page) == 1
assert page[0].ip == "10.0.0%test"