Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

A search box that JOINed to your token table: ORM filter injection in KoboToolbox (GHSA-xrq7-925g-qg5m)

TL;DR What: The asset-search endpoint in KoboToolbox kpi built a Django Q(**{field: value}) from the field name in q=, and nothing limited which fields that name could reach. The grammar allows __, so an authenticate

A search box that JOINed to your token table: ORM filter injection in KoboToolbox (GHSA-xrq7-925g-qg5m)

TL;DR

  • What: The asset-search endpoint in KoboToolbox kpi built a Django Q(**{field: value}) from the field name in q=, and nothing limited which fields that name could reach. The grammar allows __, so an authenticated user could send a relational lookup like owner__auth_token__key__startswith, which JOINs from an asset to its owner's API token.
  • Impact: The paginated count in the response works as a boolean oracle. In a local test with synthetic accounts and one project shared to the attacker, it recovered the first 10 of the owner's 40 token characters. The loop was not run further and no token was used. Rated High 8.6 (CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N) on the published GitHub advisory; CWE-203 and CWE-943.
  • Fixed in 2.026.23a (PR #7189). Advisory GHSA-xrq7-925g-qg5m, published by the KoboToolbox team, credits me as finder.

Why you should care

KoboToolbox describes itself as an open source data collection platform designed for humanitarian action, development, environmental protection, research, and social good. kpi is its server. Kobo told me it is used widely with self-hosting it does not manage, and its release note for this fix asks self-hosted installations to update as soon as possible.

Sharing is part of how the product works: the kpi repo lists sharing settings among the things its API manages. In the recorded run, the only thing the attacker needed beyond their own account was one project shared to them. From there, a plain GET on the search endpoint could test the project owner's API token one character at a time.

The setup

The asset list has a search parameter, GET /api/v2/assets/?q=<query>, parsed by a PEG grammar. The parser turns a field:value term into a Django Q object. Django ORM lookups are strings like name__icontains or owner__username: double underscores walk relationships and apply lookup functions.

Walking relationships is a feature here. The parser's own docstring uses owner__username as an example, the view's default search fields include owner__username__icontains and tags__name__icontains, and the advisory describes traversal as intended. The flaw the advisory names is that sensitive fields could be targeted.

Four filter backends run on the queryset, in this order:

# kpi/views/v2/asset.py, tag 2.026.21c (lines 436-441)
    filter_backends = [
        ExcludeOrgAssetFilter,
        KpiObjectPermissionsFilter,
        SearchFilter,
        AssetOrderingFilter,
    ]

The one that matters is KpiObjectPermissionsFilter running before SearchFilter. The permission filter scopes the queryset to assets you're allowed to see, and that set includes assets other people shared with you. The search filter then applies your q= on top of that set.

The bug

Here's the term handler that builds the Q object:

# kpi/utils/query_parser/query_parser.py, tag 2.026.21c (lines 166-176, inside term())
            field = elements[0].elements[0]

            # Bypass `status` field because it does not really exist.
            # It's only a property of Asset model.
            if field == 'status':
                return Q()

            # Trivial case, `field` is not a list inside a jsonb field.
            if not re.search(r'(\[\]$|\[\]__.+)', field):
                value = _get_value(field, elements)
                return Q(**{field: value})

There's a special case for status and a branch for jsonb list fields. Nothing limits which field a lookup can reach. Whatever field name you supply becomes the key of a Q() lookup.

And the grammar accepts underscores:

# kpi/utils/query_parser/grammar.peg, tag 2.026.21c (line 35)
  name      <- [a-zA-Z_\[\]] [a-zA-Z0-9\-_\[\]]*       %name

So owner__auth_token__key__startswith is a single valid name token. Fed into Q(**{field: value}), it becomes a relational lookup that JOINs Asset -> owner (User) -> auth_token (Token) and tests the owner's key column with a startswith match.

The search filter applies that Q to the permission-scoped queryset:

# kpi/filters.py, tag 2.026.21c (lines 483-492, inside SearchFilter.filter_queryset())
        try:
            # If we are searching on an n-to-many field, we may get multiple results
            # from the same model, so we need to de-duplicate with distinct(). Rely
            # on the view to tell us if this is not necessary
            if getattr(view, 'skip_distinct', False):
                return queryset.filter(q_obj)
            else:
                return queryset.filter(q_obj).distinct()
        except (FieldError, ValueError):
            return queryset.model.objects.none()

The leak doesn't depend on the except. A valid path that matches a row gives count=1, and a valid path that doesn't match gives count=0, through normal filtering. The except handles invalid paths: in the recorded run one returned HTTP 200 with count=0, so a bad path looks like no match. The class docstring says that empty result is there "to make the problem obvious", and the handler is still in the code after the fix.

diagram

The "aha"

The permission filter scoped the query to a row you were allowed to see, but the lookup could walk from that row to a column you were never allowed to read.

You can see the asset, because someone shared it. You cannot see the owner's API token. But owner__auth_token__key__startswith:1e doesn't return the token. It returns the asset, if and only if the token starts with 1e.

Proof of concept (benign)

This ran locally on the official kobotoolbox/kpi:2.026.21c image, with real PostgreSQL, MongoDB and Redis containers, three synthetic accounts and synthetic tokens. No live or third-party system was touched.

  • Victim (V) owns a project and has DRF token 1eb52c7152….
  • Attacker (A) has their own account and token, nothing more.
  • A was granted view_asset on V's project with asset.assign_perm, the equivalent of the share action. The run did not go through the UI. A's asset list then returned count=1.

The requests ran as A, authenticated with A's own token. My run notes summarise them like this:

GET /api/v2/assets/                                                              -> count = 1   (A sees V's shared asset)
GET /api/v2/assets/?q=owner__auth_token__key__startswith:1e   (correct prefix)   -> count = 1
GET /api/v2/assets/?q=owner__auth_token__key__startswith:zz   (wrong prefix)     -> count = 0

count flips with the guess. Iterating over [0-9a-f] recovered this token's first 10 characters. This is the script's output as copied into the same notes, with its status lines and indentation trimmed:

baseline wrong-prefix count: 0  (expect 0)
recovered so far: 1
recovered so far: 1e
recovered so far: 1eb
...
recovered so far: 1eb52c7152
VERDICT: CONFIRMED  recovered='1eb52c7152'

The recovered value matches the first 10 characters of V's synthetic token.

The controls, run in the same session:

  • No shared project (a third account with nothing shared to it): the correct 10-character prefix returned count=0.
  • Invalid path (owner__totally_bogus_field__startswith:x): HTTP 200, count=0.
  • Unauthenticated (no Authorization header, one prefix tested): HTTP 200, count=0.

Honest boundary: the run stopped at 10 of the token's 40 hex characters, because the script was set to N=10. The loop was not run further, and no recovered token was used against the API. A DRF token authenticates as its user, so the full token would give that user's API access, but that part is inferred and was not run. Only the token lookup was run, not other columns. The controls ran on the default list route and public collections were not tested, so they describe this setup and not a full boundary. The advisory rates it PR:L. Kobo reports no evidence of exploitation in the wild.

The fix

Shipped in 2.026.23a (PR #7189). Before the Q is built, term() now calls _validate_field, which walks the lookup path one __ segment at a time. It rejects a path that reaches a sensitive model (the DRF token, MFA, digest, SSO and email-confirmation models) or a column named password, private_data, secret or token. As shipped in that tag:

# kpi/utils/query_parser/query_parser.py, tag 2.026.23a (lines 47-58, 73-80, 338, 357-378; "..." marks dropped lines)
SENSITIVE_LOOKUP_MODELS = frozenset(
    {
        'authtoken.token',                 # DRF API token
        'mfa.authenticator',               # allauth MFA authenticators
        'accounts_mfa.mfamethodswrapper',  # MFA
        'django_digest.partialdigest',     # HTTP digest auth
        'account.emailconfirmation',       # e-mail confirmation
        'socialaccount.socialaccount',     # SSO account
        'socialaccount.socialtoken',       # SSO tokens
        'socialaccount.socialapp',         # SSO application
    }
)
...
SENSITIVE_LOOKUP_COLUMNS = frozenset(
    {
        'password',
        'private_data',
        'secret',
        'token',
    }
)
...
    def _validate_field(self, field):
        ...
        current_model = self.model
        for segment in field.replace('[]', '').split('__'):
            try:
                model_field = current_model._meta.get_field(segment)
            except FieldDoesNotExist:
                # A lookup/transform (e.g. `icontains`) or a `JSONField` key:
                # no further table can be reached from here
                return

            if not model_field.is_relation:
                if segment in SENSITIVE_LOOKUP_COLUMNS:
                    raise QueryParserNotSupportedFieldLookup
                # A concrete column; the rest of the path can only be lookups
                return

            related_model = model_field.related_model
            if related_model is None:
                # e.g. a generic relation that cannot be traversed in a lookup
                return
            if related_model._meta.label_lower in SENSITIVE_LOOKUP_MODELS:
                raise QueryParserNotSupportedFieldLookup
            current_model = related_model

owner__auth_token__key__startswith now stops at its second segment, because auth_token leads to authtoken.token, and the PR's tests list that lookup among the rejected ones. In the source that exception is an HTTP 400. Those tests were read, not run, and the PoC was not re-run against a patched release.

Other relational lookups, such as owner__username and parent__uid, still work, and the PR says that is intended. Kobo later replaced this denylist with a per-model allowlist, ALLOWED_LOOKUP_FIELDS, in PR #7243, first shipped in 2.026.27 on 2026-07-15.

Takeaways

  1. An ORM lookup string is query input. Q(**{field: value}) took its key from the request, so the caller chose what the query could test. Relational traversal was a legitimate feature here, so a blanket ban on __ was never an option. The question is which fields a caller may reach, and an allowlist answers that most directly. Kobo blocked sensitive models first and moved to a per-model allowlist about three weeks later (PR #7189, then PR #7243).
  2. Seeing a row did not limit what a lookup could test through it. The permission filter here answered "may this user see this asset?" It said nothing about what a lookup could reach from that asset. The JOIN went from a row the caller could see to a column they could not.
  3. A count can answer a question the caller can't ask directly. The difference between count=1 and count=0 on a valid path is the leak: one bit per query. The uniform 200 on invalid paths only removes a second signal, since a caller can't tell a bad path from no match by status code.

Disclosure timeline

  • 2026-06-16: found during source review.
  • 2026-06-23: reported by email to KoboToolbox's security address.
  • 2026-06-25: Kobo acknowledged the report and merged PR #7189.
  • 2026-06-29: 2.026.23a tagged and deployed to Kobo-managed instances, per the maintainer.
  • 2026-07-02: Kobo confirmed the fix and asked me to wait until after 2026-07-29 before posting.
  • 2026-07-09: the PoC above was run on the pre-fix 2.026.21c image (synthetic data, local only): first 10 token characters recovered.
  • 2026-07-15: 2.026.27 replaced the denylist with a per-model allowlist (PR #7243).
  • 2026-07-29: advisory GHSA-xrq7-925g-qg5m published by the KoboToolbox team, High 8.6, affected <= 2.026.23, patched >= 2.026.23a, crediting me as finder.

Credit / CTA

If you build Django filters from request input, this is a short grep worth doing today: look for Q(**{field: value}) or .filter(**...) where the key comes from the caller, then check what a __ path can reach from that model. If you self-host KoboToolbox, update; the advisory lists 2.026.23a as the first patched release.

Reported by me; the advisory credits me as finder, and the 2.026.23a release notes thank me for reporting it. Comments and corrections welcome.

Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.