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
15 changes: 15 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: TimeTracker CodeQL config

paths-ignore:
- tests/**
- "**/node_modules/**"
- "**/dist/**"
- "**/build/**"
- "**/coverage/**"
- desktop/**/node_modules/**
- mobile/**/node_modules/**
- .github/workflows-archive/**

# Security findings only (not maintainability/quality noise from security-and-quality)
queries:
- uses: security-extended
36 changes: 36 additions & 0 deletions .github/codeql/timetracker-python-models/models/sanitizers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
extensions:
- addsTo:
pack: codeql/python-all
extensible: barrierModel
data:
- ["app.utils.safe_redirect", "Member[safe_redirect_target].ReturnValue", "url-redirection"]
- ["app.utils.safe_redirect", "Member[_rebuild_relative].ReturnValue", "url-redirection"]
- ["app.utils.safe_redirect", "Member[_rebuild_same_host].ReturnValue", "url-redirection"]
- ["app.utils.error_handling", "Member[sanitize_log_value].ReturnValue", "log-injection"]
- ["app.utils.paths", "Member[safe_path_under].ReturnValue", "path-injection"]
- ["app.utils.paths", "Member[safe_path_under_or_none].ReturnValue", "path-injection"]
- ["app.utils.outbound_url", "Member[validate_outbound_url].ReturnValue", "request-forgery"]
- ["app.utils.outbound_url", "Member[validate_oidc_issuer_url].ReturnValue", "request-forgery"]

- addsTo:
pack: codeql/python-all
extensible: barrierGuardModel
data:
- [
"app.utils.safe_redirect",
"Member[is_safe_next_url].Argument[0,next_url:]",
"true",
"url-redirection",
]
- [
"app.utils.safe_redirect",
"Member[is_safe_referrer].Argument[0,referrer:]",
"true",
"url-redirection",
]
- [
"app.utils.safe_redirect",
"Member[is_safe_relative_url].Argument[0,next_url:]",
"true",
"url-redirection",
]
7 changes: 7 additions & 0 deletions .github/codeql/timetracker-python-models/qlpack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: drytrix/timetracker-python-models
version: 0.0.1
library: true
extensionTargets:
codeql/python-all: "*"
dataExtensions:
- models/**/*.yml
48 changes: 48 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CodeQL

on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
schedule:
# Weekly re-scan (Mondays 06:00 UTC)
- cron: "0 6 * * 1"
workflow_dispatch:

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write

strategy:
fail-fast: false
matrix:
include:
- language: python
build-mode: none
- language: javascript-typescript
build-mode: none

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
env:
# Load local models-as-data pack for Python only (safe_redirect, safe_path_under, …)
CODEQL_ACTION_EXTRA_OPTIONS: ${{ matrix.language == 'python' && format('{{"database":{{"run-queries":["--additional-packs={0}/.github/codeql","--extension-packs=drytrix/timetracker-python-models"]}}}}', github.workspace) || '' }}
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.18.2] - 2026-10-13

### Security

- **CodeQL / XSS / redirects** — Clear CodeQL findings; harden DOM sinks and PDF templates; block open redirects via referrer/`next`; centralize `safe_redirect`; keep 5xx API errors generic; rebuild OIDC discovery URLs from validated issuer components.
- **Desktop (Dependabot #222)** — Override transitive `sprintf-js` with a vendored build that caps numeric precision (GHSA-hp3w-g68c-fv3c / CVE-2026-97058) until upstream publishes a fixed release.
- **CSP** — Add nonces to remaining inline scripts.

### Fixed

- **Timer review / idle response** — Import `db` in timer review and idle-response handlers.
- **Docker** — Refresh base packages and upgrade pip/setuptools.
- **Models** — Strip UTF-8 BOMs from timesheet/time-off model modules.

### Changed

- **CI** — Add CodeQL workflow to resume code scanning.

### Documentation

- **Version** — Bumped `setup.py` to **5.18.2**; `VERSION` and desktop/browser-extension/mobile client versions aligned.

## [5.18.1] - 2026-10-11

### Fixed
Expand Down
13 changes: 9 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ ENV TZ=Europe/Rome
ENV DONATE_HIDE_PUBLIC_KEY_FILE=/app/donate_hide_public.pem
LABEL org.opencontainers.image.description="Self-hosted time tracking web application for projects, clients, and reports."

# Install all system dependencies in a single layer
# Install all system dependencies in a single layer.
# apt-get upgrade picks up security fixes for the slim-bookworm base image.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
# Core utilities
curl \
tzdata \
Expand Down Expand Up @@ -73,10 +76,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# Set work directory
WORKDIR /app

# Install Python dependencies with cache mount for pip
# Upgrade pip/setuptools first (base image pins are often stale / CVE-flagged),
# then install app requirements (includes Authlib pin from requirements.txt).
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
pip install --no-cache-dir --upgrade "pip>=25.0" "setuptools>=78.1.1" wheel \
&& pip install --no-cache-dir -r requirements.txt

# Create non-root user early (before copying files)
RUN useradd -m -u 1000 timetracker
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ docker compose -f docker-compose.nas.yml up -d
# open http://localhost:8080 — first login creates the admin
```

**Latest release: v5.18.1** — see [CHANGELOG.md](CHANGELOG.md).
**Latest release: v5.18.2** — see [CHANGELOG.md](CHANGELOG.md).

More install paths (HTTPS production, NAS UI paste, cloud): [Install options](#install-options).

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.18.1
5.18.2
104 changes: 60 additions & 44 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,21 @@ def _rate_limit_key():
def log_event(name: str, **kwargs):
"""Log an event with structured JSON format including request context"""
try:
extra = {"request_id": getattr(g, "request_id", None), "event": name, **kwargs}
from app.utils.error_handling import sanitize_log_value

safe_name = sanitize_log_value(name, max_len=200)
safe_kwargs = {k: sanitize_log_value(v) for k, v in kwargs.items()}
extra = {"request_id": getattr(g, "request_id", None), "event": safe_name, **safe_kwargs}
try:
from app.telemetry.otel_setup import get_trace_context_for_logs, is_otel_tracing_active

if is_otel_tracing_active():
extra.update(get_trace_context_for_logs())
except Exception:
logging.getLogger(__name__).debug("Could not attach trace context to log_event", exc_info=True)
json_logger.info(name, extra=extra)
json_logger.info(safe_name, extra=extra)
except Exception as e:
logging.getLogger(__name__).debug("Structured log_event failed: %s", e)
logging.getLogger(__name__).debug("Structured log_event failed: %s", type(e).__name__)


def identify_user(user_id, properties=None):
Expand Down Expand Up @@ -600,13 +604,23 @@ def _normalize_locale(locale_code):
def _handle_unauthorized():
"""Send portal sessions to the client portal login instead of the main app login."""
from app.utils.client_portal_session import get_active_native_client_portal_id
from app.utils.safe_redirect import is_safe_relative_url

# Prefer a same-origin relative path for next= (never pass absolute request.url)
next_target = request.path or "/"
if request.query_string:
try:
next_target = f"{request.path}?{request.query_string.decode('utf-8', 'ignore')}"
except Exception:
next_target = request.path or "/"
next_kw = {"next": next_target} if is_safe_relative_url(next_target) else {}

if get_active_native_client_portal_id() or request.path.startswith("/client-portal"):
flash(_("Please log in to access the client portal."), "error")
return redirect(url_for("client_portal.login", next=request.url))
return redirect(url_for("client_portal.login", **next_kw))

flash(login_manager.login_message, login_manager.login_message_category)
return redirect(url_for(login_manager.login_view, next=request.url))
return redirect(url_for(login_manager.login_view, **next_kw))

# Internationalization selector handled via babel.init_app(locale_selector=...)

Expand Down Expand Up @@ -855,12 +869,14 @@ def prom_start_timer():
def log_login_requests():
try:
if request.path == "/login":
from app.utils.error_handling import sanitize_log_value

app.logger.info(
"%s %s from %s UA=%s",
request.method,
request.path,
request.headers.get("X-Forwarded-For") or request.remote_addr,
request.headers.get("User-Agent"),
sanitize_log_value(request.method),
sanitize_log_value(request.path),
sanitize_log_value(request.headers.get("X-Forwarded-For") or request.remote_addr),
sanitize_log_value(request.headers.get("User-Agent")),
)
except Exception:
app.logger.debug("log_login_requests failed", exc_info=True)
Expand Down Expand Up @@ -889,12 +905,14 @@ def record_metrics_and_log(response):
try:
# Log write operations
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
from app.utils.error_handling import sanitize_log_value

app.logger.info(
"%s %s -> %s from %s",
request.method,
request.path,
sanitize_log_value(request.method),
sanitize_log_value(request.path),
response.status_code,
request.headers.get("X-Forwarded-For") or request.remote_addr,
sanitize_log_value(request.headers.get("X-Forwarded-For") or request.remote_addr),
)
except Exception:
app.logger.debug("Write-operation request logging failed", exc_info=True)
Expand Down Expand Up @@ -1144,15 +1162,17 @@ def handle_csrf_error(e):
user_id = getattr(_cu, "id", None) if getattr(_cu, "is_authenticated", False) else None
except Exception:
user_id = None
from app.utils.error_handling import sanitize_log_value

app.logger.warning(
"CSRF failure: path=%s method=%s form=%s json=%s ref=%s user=%s reason=%s",
request.path,
request.method,
sanitize_log_value(request.path),
sanitize_log_value(request.method),
bool(request.form),
request.is_json,
request.referrer,
sanitize_log_value(request.referrer),
user_id,
getattr(e, "description", ""),
sanitize_log_value(getattr(e, "description", "")),
)
except Exception:
app.logger.debug("CSRF failure diagnostic logging failed", exc_info=True)
Expand All @@ -1172,29 +1192,23 @@ def handle_csrf_error(e):
except Exception:
is_authed = False

from app.utils.safe_redirect import is_safe_next_url, safe_redirect_target

if (not is_authed) or request.endpoint == "auth.login" or request.path.rstrip("/").endswith("/login"):
next_url = request.args.get("next") or request.form.get("next")
if next_url:
if next_url and is_safe_next_url(next_url):
dest = url_for("auth.login", next=next_url)
else:
dest = url_for("auth.login")
else:
dest = url_for("main.dashboard")
try:
# Prefer returning to the same path that failed when no referrer is present
if request.path:
dest = request.path
except Exception:
pass
try:
ref = request.referrer
if ref:
ref_host = urlparse(ref).netloc
cur_host = urlparse(request.host_url).netloc
if ref_host and ref_host == cur_host:
dest = ref
except Exception:
app.logger.debug("CSRF redirect referrer parse failed", exc_info=True)
dest = safe_redirect_target(
request.referrer,
allow_absolute_same_host=True,
fallback=request.path if (request.path or "").startswith("/") else None,
fallback_endpoint="main.dashboard",
)
# dest is url_for(...) or safe_redirect_target(...)
# codeql[py/url-redirection]
return redirect(dest)

# JSON/XHR fall-through
Expand Down Expand Up @@ -1224,21 +1238,23 @@ def handle_csrf_error(e):
is_authed = False

if (not is_authed) or request.endpoint == "auth.login" or request.path.rstrip("/").endswith("/login"):
from app.utils.safe_redirect import is_safe_next_url

next_url = request.args.get("next") or request.form.get("next")
if next_url:
if next_url and is_safe_next_url(next_url):
return redirect(url_for("auth.login", next=next_url))
return redirect(url_for("auth.login"))

dest = request.path or url_for("main.dashboard")
try:
ref = request.referrer
if ref:
ref_host = urlparse(ref).netloc
cur_host = urlparse(request.host_url).netloc
if ref_host and ref_host == cur_host:
dest = ref
except Exception:
app.logger.debug("CSRF redirect referrer parse failed", exc_info=True)
from app.utils.safe_redirect import safe_redirect_target

dest = safe_redirect_target(
request.referrer,
allow_absolute_same_host=True,
fallback=request.path if (request.path or "").startswith("/") else None,
fallback_endpoint="main.dashboard",
)
# dest produced by safe_redirect_target
# codeql[py/url-redirection]
return redirect(dest)

# Expose csrf_token() in Jinja templates even without FlaskForm
Expand Down
Loading
Loading