Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ on:
schedule:
# Mondays at 06:00 UTC.
- cron: '0 6 * * 1'
# Manual refresh of the main-branch alert set (e.g. right after merging
# security fixes) — dispatch runs don't gate Railway deploys.
workflow_dispatch:

permissions:
actions: read
Expand Down
5 changes: 3 additions & 2 deletions backend/server/api/aa.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from backend.census.store import StoreRecord
from backend.census.store import store as census_store
from backend.core.log_safety import scrub
from backend.eq2db.aas import catalogue as aa_db
from backend.eq2db.spells import catalogue as spells_db
from backend.server.cache import aa_cache
Expand Down Expand Up @@ -225,7 +226,7 @@ def _write() -> None:
await run_sync(_write)
aa_cache.set(cache_key, result)
except Exception as exc:
_log.warning("[cache] Background AA refresh failed for %s: %s", name, exc)
_log.warning("[cache] Background AA refresh failed for %s: %s", scrub(name), exc)


@router.get("/character/{name}/aas", response_model=CharAAsResponse)
Expand Down Expand Up @@ -270,7 +271,7 @@ def _read() -> StoreRecord | None:
from backend.server import census_health

if census_health.is_down():
_log.debug("[aa] Skipping live fetch — census_health=down (name=%s)", name)
_log.debug("[aa] Skipping live fetch — census_health=down (name=%s)", scrub(name))
raise HTTPException(
status_code=503,
detail=f"'{name}' AA data not cached yet and Census is unavailable. Try again shortly.",
Expand Down
8 changes: 6 additions & 2 deletions backend/server/api/act/xml_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

import xml.etree.ElementTree as ET

# User-pasted XML — parse with defusedxml so entity-expansion bombs
# (billion laughs) and external entities are rejected, not expanded.
from defusedxml import ElementTree as SafeET
from defusedxml.common import DefusedXmlException
from fastapi import HTTPException


Expand Down Expand Up @@ -136,8 +140,8 @@ def parse_import_xml(text: str) -> tuple[list[dict], list[dict]]:

wrapped = f"<__import_root>{body}</__import_root>"
try:
root = ET.fromstring(wrapped)
except ET.ParseError as exc:
root = SafeET.fromstring(wrapped)
except (ET.ParseError, DefusedXmlException) as exc:
raise HTTPException(status_code=400, detail=f"Invalid XML: {exc}") from exc

triggers = [_trigger_from_element(t) for t in root.iter("Trigger") if (t.get("Regex") or t.get("R"))]
Expand Down
5 changes: 3 additions & 2 deletions backend/server/api/character/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from pydantic import BaseModel

from backend.census.item_level import adorn_bonus, character_ilvl
from backend.core.log_safety import scrub
from backend.eq2db.items import GearRow
from backend.eq2db.items import catalogue as _items
from backend.server.api.character import router # the package-level router
Expand Down Expand Up @@ -506,7 +507,7 @@ async def get_character(request: Request, name: str) -> CharacterResponse:
finally:
conn2.close()
except Exception as exc:
_log.debug("[character] self-heal cache write skipped for %s: %s", name, exc)
_log.debug("[character] self-heal cache write skipped for %s: %s", scrub(name), exc)
character_cache.set(cache_key, resp)
return resp

Expand All @@ -515,7 +516,7 @@ async def get_character(request: Request, name: str) -> CharacterResponse:
from backend.server import census_health

if census_health.is_down():
_log.debug("[character] Skipping live fetch — census_health=down (name=%s)", name)
_log.debug("[character] Skipping live fetch — census_health=down (name=%s)", scrub(name))
raise HTTPException(
status_code=503,
detail=f"'{name}' not cached yet and Census is unavailable. Try again shortly.",
Expand Down
5 changes: 3 additions & 2 deletions backend/server/api/characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from fastapi import APIRouter, Request
from pydantic import BaseModel

from backend.core.log_safety import scrub
from backend.server.cache import character_cache
from backend.server.core.cache_keys import char_cache_key
from backend.server.core.census_lifecycle import shared_census_client
Expand Down Expand Up @@ -85,9 +86,9 @@ async def search_characters(request: Request, name: str = "") -> CharSearchRespo
if brief:
raw = [brief]
except Exception as exc:
_log.debug("[characters] Exact-name fallback failed for %r: %s", q, exc)
_log.debug("[characters] Exact-name fallback failed for %r: %s", scrub(q), exc)
except Exception as exc:
_log.warning("[characters] Census search failed for %r: %s", q, exc)
_log.warning("[characters] Census search failed for %r: %s", scrub(q), exc)
raw = []

if raw:
Expand Down
2 changes: 1 addition & 1 deletion backend/server/api/claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ async def create_claim(request: Request, body: SubmitClaimRequest) -> ClaimRespo
from backend.server import census_health

if census_health.is_down():
_log.debug("[claim] Skipping live fetch — census_health=down (name=%s)", name)
_log.debug("[claim] Skipping live fetch — census_health=down (name=%s)", _safe_for_log(name))
raise HTTPException(
status_code=503,
detail="Census is unavailable. Cannot verify character existence — try again shortly.",
Expand Down
2 changes: 1 addition & 1 deletion backend/server/api/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ async def search_guilds(name: str = "") -> GuildSearchResponse:
async with shared_census_client() as client:
raw = await client.search_guilds_by_name(q, current_world())
except Exception as exc:
_log.warning("[guild] Census guild search failed for %r: %s", q, exc)
_log.warning("[guild] Census guild search failed for %r: %s", _scrub(q), exc)
raw = []

if raw:
Expand Down
3 changes: 2 additions & 1 deletion backend/server/api/parses/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from fastapi import BackgroundTasks, HTTPException, Request

from backend.census.store import store as census_store
from backend.core.log_safety import scrub
from backend.server.api.parses import router
from backend.server.api.parses.list import _classify_zone
from backend.server.api.parses.models import (
Expand Down Expand Up @@ -140,7 +141,7 @@ async def _resolve_uploader_guild_async(
async with shared_census_client() as client:
guild_name = await client.get_character_guild_name(uploader, effective_world)
except Exception as exc:
_log.warning("Census guild lookup failed for %r: %s", uploader, exc)
_log.warning("Census guild lookup failed for %r: %s", scrub(uploader), exc)
return CENSUS_UNAVAILABLE

if not guild_name:
Expand Down
9 changes: 5 additions & 4 deletions backend/server/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import time
from typing import Any, Literal

from backend.core.log_safety import scrub
from backend.server.constants import CACHE_MAX_AGE_S, CACHE_STALE_TTL_S
from backend.server.core.silent_swallow import swallow

Expand Down Expand Up @@ -103,19 +104,19 @@ def get_stale(self, key: str) -> tuple[Any | None, bool]:
if self._max_age is not None and age > self._max_age:
del self._store[key]
self._update_size()
_log.debug("[cache] EXPIRED %s (%.1f min old)", key, age / 60)
_log.debug("[cache] EXPIRED %s (%.1f min old)", scrub(key), age / 60)
self._inc_miss()
return None, False
is_stale = age > self._ttl
_log.debug("[cache] %s %s", "STALE" if is_stale else "HIT ", key)
_log.debug("[cache] %s %s", "STALE" if is_stale else "HIT ", scrub(key))
if is_stale:
self._inc_stale()
else:
self._inc_hit()
return value, is_stale

def set(self, key: str, value: Any) -> None:
_log.debug("[cache] SET %s", key)
_log.debug("[cache] SET %s", scrub(key))
# Evict oldest entry if we're at capacity and this is a new key.
if key not in self._store and len(self._store) >= self._maxsize:
oldest_key = next(iter(self._store))
Expand Down Expand Up @@ -148,7 +149,7 @@ def sweep(self) -> int:

def delete(self, key: str) -> None:
self._store.pop(key, None)
_log.debug("[cache] DEL %s", key)
_log.debug("[cache] DEL %s", scrub(key))
self._update_size()


Expand Down
4 changes: 2 additions & 2 deletions backend/server/core/audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ def audit_log(action: str, actor: str | None, **fields: Any) -> None:
"""
safe_fields: dict[str, Any] = {(f"{k}_" if k in _LOGRECORD_RESERVED else k): scrub(v) for k, v in fields.items()}
safe_fields["action"] = action
safe_fields["actor"] = actor or "-"
safe_fields["actor"] = scrub(actor) if actor else "-"
safe_fields["request_id"] = request_id_var.get() or "-"
safe_fields["world"] = world_var.get() or "-"
# The message string is intentionally short — dashboards filter on
# extra= fields, not on message body. Keep the human-readable part
# action + actor so text logs are still grep-friendly.
_log.info("audit: %s actor=%s", action, actor or "-", extra=safe_fields)
_log.info("audit: %s actor=%s", action, scrub(actor) if actor else "-", extra=safe_fields)
3 changes: 2 additions & 1 deletion backend/server/guild_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from backend.census.constants import SPELL_TIER_ORDER as _TIER_ORDER
from backend.census.models import CharacterOverview, GuildData, SpellEntry
from backend.census.store import store as census_store
from backend.core.log_safety import scrub
from backend.eq2db.spells import (
DB_PATH as _SPELLS_DB,
)
Expand Down Expand Up @@ -395,7 +396,7 @@ async def _do_fetch():
try:
return await task
except Exception as exc:
_log.warning("[cache] Guild fetch failed for %s: %s", guild_name, exc)
_log.warning("[cache] Guild fetch failed for %s: %s", scrub(guild_name), exc)
return None
finally:
if _guild_fetch_tasks.get(task_key) is task:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ dependencies = [
"httpx>=0.27.0",
"itsdangerous>=2.2.0",
"slowapi>=0.1.9",
# Hardened XML parsing for the ACT paste-import (entity-expansion safe)
"defusedxml>=0.7.1",
# IANA timezone database — Python's zoneinfo has no bundled tz data on
# Windows (and minimal containers), so available_timezones()/ZoneInfo need
# this for the raid-schedule timezone validation + live-window checks to
Expand Down
4 changes: 3 additions & 1 deletion tests/server/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,6 @@ async def test_login_redirects_to_discord(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test", follow_redirects=False) as client:
response = await client.get("/api/auth/login")
assert response.status_code in (302, 307)
assert "discord.com" in response.headers["location"]
from urllib.parse import urlparse

assert urlparse(response.headers["location"]).hostname == "discord.com"
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading