/dev/posts/

Risk of reflected cross site scripting and Content-Security-Policy bypass in the WebSub intent verification

Published:

Updated:

I was reading the WebSub specification (formerly PubSubHubbub) when I found that there was a risk of reflected browser-side code injection (reflected cross site scripting, reflected XSS) in the WebSub intent verification exchange.

The WebSub specification has been updated to reflect this.

Acknowledgement: thanks to the W3C people and especially Simone Onofri. 👋

Main references:

Table of content

Summary

Impacted: web applications implementing the WebSub subscriber role (i.e. web applications subscribing to web hooks) MAY be impacted (depending on the implementation).

Impact: an attacker may execute arbitrary JavaScript code in the victim's browser for the for the vulnerable web application. This typically could be used to execute code on the user behalf, exfiltrate user data, etc.

Vulnerabilities:

As a developer: do not forget to include an explicit Content-Type in your responses! Use X-Content-Type-Options: nosniff in the response.

As an attacker: If you target server implements the WebSub (webhooks) subscriber role, you might be able to exploit the WebSub intent verification request/response in order to trigger a reflected cross-site scripting (XSS) attack, through the hub.challenge parameter, depending on which HTTP header fields are included in the response.

Description

As part of the intent verification, the hub makes an intent verification request to the subscriber and the subscriber is expected to echo back the raw value of the hub.challenge parameter as the response body:

hub.challenge: REQUIRED. A hub-generated, random string that MUST be echoed by the subscriber to verify the subscription.

[...]

The subscriber MUST confirm that the hub.topic corresponds to a pending subscription or unsubscription that it wishes to carry out. If so, the subscriber MUST respond with an HTTP success (2xx) code with a response body equal to the hub.challenge parameter. If the subscriber does not agree with the action, the subscriber MUST respond with a 404 "Not Found" response.

The specification does not specify which Content-Type should be used for the response.

An attacker could redirect a target user's browser to a URI containing malicious HTML and JavaScript in the hub.challenge in order to attempt to trigger a reflected XSS attack on the WebSub subscriber origin:

https://subscriber.example.com/callbacks/XXXX
  ?hub.mode=subscribe
  &hub.topic=https://attacker.example.com/topic
  &hub.challenge=<script>...</script>
  &hub.lease_seconds=600

If the target user has a session on https://subscriber.example.com, the attacker could then be able to execute code on the target user's behalf.

Scenario :

  1. The attacker redirects the target user on a server he control (https://attacker.example.com/).
  2. This malicious server initiates a WebSub subscription on https://subscriber.example.com to itself (using the attacker session) on the target subscriber application. and obtains a callback URI (https://subscriber.example.com/callbacks/XXX).
  3. The attacker server redirects the target user's browser to a subscription confirmation URI containing a malicious JavaScript payload in the hub.challenge parameter (eg. https://subscriber.example.com/callbacks/XXXX?...&hub.challenge=<script>...</script>).
  4. The user browser browses to this URI, receives the malicious payload (<script>...</script>) from the server and executes the JavaScript code in the context of the target user session.
  5. The malicious payload may for example trigger requests on https://subscriber.example.com using the target user session (on behalf of the target user).
Sequence diagram of a reflected XSS attack exploiting the WebSub intent verification

There are several assumptions for this to work:

Many web frameworks, use text/html as a default response Content-Type. When using such a framework, the intent verification response is vulnerable to reflected XSS if the Content-Type is not explicitly set.

Example against WebSub Rocks!

WebSub Rocks is a validator used to test WebSub implementations and is references in the WebSub specification as the WebSub test suite. It turn out that WebSub Rocks was vulnerable to this attack.

Here is the subscription request I received from https://websub.rocks/hub/100:

POST / HTTP/1.1
Host: odin.urdhr.fr:9998
Accept: */*
User-Agent: WebSub.rocks/1.0 (WebSub Validator https://www.w3.org/TR/websub/)
Content-Length: 177
Content-Type: application/x-www-form-urlencoded

hub.mode=subscribe
&hub.topic=https%3A%2F%2Fwebsub.rocks%2Fhub%2F100%2Fpub%2FKYlqZBFMgZDU3CdBTGjP
&hub.callback=https%3A%2F%2Fwebsub.rocks%2Fhub%2F100%2Fsub%2FKYlqZBFMgZDU3CdBTGjP

Now we can create a URI of the form:

https://websub.rocks/hub/100/sub/KYlqZBFMgZDU3CdBTGjP
  ?hub.mode=subscribe
  &hub.topic=https://websub.rocks/hub/100/pub/KYlqZBFMgZDU3CdBTGjP
  &hub.challenge=<script>alert(window.location);</script>
  &hub.lease_seconds=600

Which gives the following response:

HTTP/2 200
server: nginx/1.24.0
date: Mon, 21 Oct 2024 22:47:05 GMT
content-type: text/html; charset=UTF-8

<script>alert(window.location);</script>

Notice how the content-type here is set to text/html. This is because the corresponding PHP server-side code does not set an explicit content-type:

<?php
public function get_subscriber(ServerRequestInterface $request, $args) {
  # ...
  $response->getBody()->write($params['hub_challenge']);
  return $response;
}

In this case, PHP defaults to text/html.

Screenshot of reflected XSS through WebSub intent verification response in WebSub Rocks!

There is certainly no real-world impact for this particular website but this demonstrates that a typical implementation might be vulnerable to this kind of attack.

This has been since been mitigated by explicitly using the application/octet-stream media type:

<?php
public function get_subscriber(ServerRequestInterface $request, $args) {
  # ...
  $response->getBody()->write($params['hub_challenge']);
  $response = $response->withHeader('Content-Type', 'application/octet-stream');
  return $response;
}

Impact

Applications which act as WebSub subscribers might be impacted. This is especially true when they use a web framework which uses text/html by default (many do).

Searching for hub.challenge and hub_challenge on code search engines yields a lots of code samples which do not explicitly set a content-type in the WebSub intent verification response. Many of them use a web framework which serves text/html by default. These code samples appears to be vulnerable to the XSS attack although further analysis would be necessary in order to evaluate if this is exploitable in practice and has a real-world impact.

Impact:

Other potential attacks

Content-Security-Policy bypass

Even if the WebSub intent verification response uses a safe Content-Type, it still might be used to bypass Content-Security-Policy (CSP) protections.

Assumptions:

In this scenario, the attacker uses the XSS vulnerability to load the WebSub intent verification URI as JavaScript in the target user's browser:

<script src="/callbacks/XXXX
  ?hub.mode=subscribe
  &hub.topic=https://attacker.example.com/topic
  &hub.challenge=alert(window.location);
  &hub.lease_seconds=600
"></script>

For example, in the case of reflected XSS, this would be done by redirecting the user's browser to URI of the form:

https://subscriber.example.com/q?=%3Cscript%20src%3D%22/callbacks/XXXX%
  3Fhub.mode%3Dsubscribe
  %26hub.topic%3Dhttps%3A//attacker.example.com/topic
  %26hub.challenge%3Dalert%28window.location%29%3B
  %26hub.lease_seconds%3D600
  %22%3E%3C/script%3E

The browser would then load the following URI as JavaScript:

https://subscriber.example.com/callbacks/XXXX
  ?hub.mode=subscribe
  &hub.topic=https://attacker.example.com/topic
  &hub.challenge=alert(window.location);
  &hub.lease_seconds=600

The response is of the form:

HTTP 200 OK
Content-Type: application/octet-stream
...

alert(window.location);

The browser executes the malicious payload:

alert(window.location);
Sequence diagram of a CSP by pass exploiting the WebSub intent verification

This can be mitigated by making sure that MIME sniffing is disabled in the response (using X-Content-Type-Options: nosniff).

Warning: using the proper Content-Type is not enough

Using Content-Type: application/octet-stream is not enough to protect against this attacks as demonstrated by the following screenshot.

CSP bypass through WebSub intent verification response served as application/octet-stream

Serving other malicious resources

The intent verification procedure could be used to trick users into downloading a malicious resource from a trusted vulnerable web application. The attacker could exploit the trust the victim would have on the target server in order to trick the user into trusting the malicious resource.

Mitigation: Subscribers SHOULD impose restrictions on the hub.challenge parameter such as limiting its length, rejecting binary data, rejecting control characters, non-ASCII data.

Recommendations

For WebSub subscribers, I would recommend:

The WebSub recommendation should warn against these attacks and strongly recommend the usage of these mitigations.

These mitigations have been incorporated in the updated WebSub specification.

References

Repositories:

Appendix, other vulnerable code

Searching for hub.challenge or hub_challenge yields a lof of results, many (most) of them appear to be vulnerable. This issue therefore appears to be quite prevalent in WebSub subscriber implementations.

This appendix shows and dicusses some examples.

Searches:

Websubsub

Snippet from Websubsub:

from django.http import HttpResponse

...

def on_unsubscribe(self, request, ssn):
    if 'hub.challenge' not in request.GET:
        logger.error(f'Missing hub.challenge in unsubscription verification {ssn.pk}!')
        tasks.save.delay(
            pk = ssn.pk,
            unsubscribe_status = 'verifyerror',
            verifyerror_count = ssn.verifyerror_count + 1
        )
        return Response('Missing hub.challenge', status=HTTP_400_BAD_REQUEST)

    tasks.save.delay(
        pk = ssn.pk,
        unsubscribe_status = 'verified',
        #lease_expiration_time = None,  # TODO: should we reset it?
        connerror_count = 0,
        huberror_count = 0,
        verifyerror_count = 0,
        verifytimeout_count = 0
    )
    logger.info(f'Got {ssn.pk} unsubscribe confirmation from hub.')
    return HttpResponse(request.GET['hub.challenge'])

Note on djanog HttpRequest:

content_type is the MIME type optionally completed by a character set encoding and is used to fill the HTTP Content-Type header. If not specified, it is formed by 'text/html' and the DEFAULT_CHARSET settings, by default: "text/html; charset=utf-8".

go-websub

Snippet from go-websub:

w.WriteHeader(200)
w.Write([]byte(q.Get("hub.challenge")))

This uses go http module which quite conveniently autodetects the response Content-Type if it is not explicitly set[1]:

If ResponseWriter.WriteHeader has not yet been called, Write calls WriteHeader(http.StatusOK) before writing the data. If the Header does not contain a Content-Type line, Write adds a Content-Type set to the result of passing the initial 512 bytes of written data to DetectContentType. Additionally, if the total size of all written data is under a few KB and there are no Flush calls, the Content-Length header is added automatically.

flow-dashboard

Snippet from flow-dashboard:

@authorized.role()
def fbook_request(self, d):
    '''
    Facebook Messenger request handling
    '''
    verify_token = self.request.get('hub.verify_token')
    hub_challenge = self.request.get('hub.challenge')
    if verify_token and verify_token == secrets.FB_VERIFY_TOKEN:
        if hub_challenge:
            self.response.out.write(hub_challenge)
            return

    from services.agent import FacebookAgent
    fa = FacebookAgent(self.request)
    fa.send_response()
    self.success = True
    self.json_out({})

This uses HTML content type.

pokedex-go

Snippet from pokedex-go:

app.get('/webhook', (req, res) => {
	if (req.query['hub.mode'] === 'subscribe' &&
			req.query['hub.verify_token'] === VALIDATION_TOKEN) {
		console.log('validating webhook');
		res.status(200).send(req.query['hub.challenge']);
	} else {
		console.log('failed validation.');
		res.sendStatus(403);
	}
});

yodabot

Snipper from yodabot:

@app.route("/", methods=['POST', 'GET'])
def main():
  if request.method == 'GET':
    verification_code = 'bot_this_is_of_yoda'
    verify_token = request.args.get("hub.verify_token")
    if verification_code == verify_token:
      return request.args.get("hub.challenge")
    else:
      return 'hello world'
  ...

bigdateros-whatsappbot-python

Snippet from bigdateros-whatsappbot-python:

@app.route('/webhook', methods=['GET'])
def verificar_token():
    try:
        token = request.args.get('hub.verify_token')
        challenge = request.args.get('hub.challenge')

        if token == sett.token and challenge != None:
            return challenge
        else:
            return 'token incorrecto', 403
    except Exception as e:
        return e,403

ngrok-webhook-nodejs-sample

Snippet from ngrok-webhook-nodejs-sample:

app.get("/*", (req, res) => {
  // Parse the query params
  var mode = req.query["hub.mode"];
  var token = req.query["hub.verify_token"];
  var challenge = req.query["hub.challenge"];

  console.log("-------------- New Request GET --------------");
  console.log("Headers:"+ JSON.stringify(req.headers, null, 3));
  console.log("Body:"+ JSON.stringify(req.body, null, 3));

  // Check if a token and mode is in the query string of the request
  if (mode && token) {
    // Check the mode and token sent is correct
    if (mode === "subscribe" && token === "12345") {
      // Respond with the challenge token from the request
      console.log("WEBHOOK_VERIFIED");
      res.status(200).send(challenge);
    } else {
      console.log("Responding with 403 Forbidden");
      // Respond with '403 Forbidden' if verify tokens do not match
      res.sendStatus(403);
    }
  } else {
    console.log("Replying Thank you.");
    res.json({ message: "Thank you for the message" });
  }
});

appboy-fb-messenger-bot

Snipp from appboy-fb-messenger-bot:

app.get('/webhook/', function (req, res) {
    if (req.query['hub.verify_token'] === process.env.FACEBOOK_VERIFY_TOKEN) {
        res.send(req.query['hub.challenge']);
    }
    res.send('Error, wrong token');
});

wa_me

Snippet from wa_me:

@app.get("/")
async def ping():
    if request.args.get("hub.verify_token") == "VERIFY_TOKEN":
        return request.args.get("hub.challenge")
    return "Invalid verify token"

Py_FBM

Snippet from Py_FBM:

function validation (req, res) {
  if (req.param('hub.mode') === 'subscribe' && req.param('hub.verify_token') === token) {
    res.send(req.param('hub.challenge'))
  } else {
    res.sendStatus(400)
  }
}
@app.route('/webhook',methods=['GET', 'POST'])
def handleFB():
  if req.method == 'POST':
    data = req.get_json()
    run(data)
    return 'hi'
  else:
    if req.args.get('hub.verify_token') == 'funny_aha_taipei_aha_robot_fb' :
      return req.args.get('hub.challenge')
    else:
      return 'Error, wrong validation token

parallel-ogram

Snippet from parallel-ogram:

<?php

if (get_str("hub_mode") == "subscribe"){
  # ...
  $challenge = get_str("hub_challenge");
	$verify = get_str("hub_verify_token");
  # ...
  if ($verify != $subscription['verify_token']){
		error_403();
	}
  # ...
  echo $challenge;
	exit();
}

PHP uses text/html by default.

Flask-WebSub

Snippet from Flask-WebSub:

def confirm_subscription(callback_id):
    with warn_and_abort_on_error(callback_id):
        subscription_request = subscriber.temp_storage.pop(callback_id)

    mode = get_query_arg('hub.mode')
    topic_url = get_query_arg('hub.topic')
    if mode != subscription_request['mode']:
        abort(404, "Mode does not match with last request")
    if topic_url != subscription_request['topic_url']:
        abort(404, "Topic url does not match")
    if mode == 'subscribe':
        lease = parse_lease_seconds(get_query_arg('hub.lease_seconds'))
        subscription_request['lease_seconds'] = lease
        # this is the point where the subscription request is turned into
        # a subscription:
        subscriber.storage[callback_id] = subscription_request
    else:  # unsubscribe
        del subscriber.storage[callback_id]
    subscriber.call_all('success_handlers', topic_url, callback_id, mode)
    return get_query_arg('hub.challenge'), 200

fbsamples

The code esamples in messenger-platform-samples appears to be vulnerable. In the case of Facebook, a secret value for the hub.verify_token must be passed in order to trigger the vulnerability. This means that whoever knows this secret token (i.e. Facebook) as reflected XSS power in your application!

Example in Flask (uses HTML by default):

@app.route("/webhook", methods=["GET", "POST"])
def webhook():
    # Webhook verification
    if request.method == "GET":
        if request.args.get("hub.mode") == "subscribe" and request.args.get(
            "hub.challenge"
        ):
            if not request.args.get("hub.verify_token") == TOKEN:
                return "Verification token mismatch", 403
            print("WEBHOOK_VERIFIED")
            return request.args["hub.challenge"], 20
    elif request.method == "POST":
      ...

Example in Express:

app.get('/webhook', (req, res) => {
  if (req.query['hub.verify_token'] === env.VERIFY_TOKEN) {
    res.send(req.query['hub.challenge']);
  }
});

Another example in Express:

// Accepts GET requests at the /webhook endpoint
app.get('/webhook', (req, res) => {

    const VERIFY_TOKEN = process.env.TOKEN;

    // Parse params from the webhook verification request
    let mode = req.query['hub.mode'];
    let token = req.query['hub.verify_token'];
    let challenge = req.query['hub.challenge'];

    // Check if a token and mode were sent
    if (mode && token) {

        // Check the mode and token sent are correct
        if (mode === 'subscribe' && token === VERIFY_TOKEN) {

            // Respond with 200 OK and challenge token from the request
            console.log('WEBHOOK_VERIFIED');
            res.status(200).send(challenge);

        } else {
            // Responds with '403 Forbidden' if verify tokens do not match
            res.sendStatus(403);
        }
    }
});

Note: Meta Bug Bounty program

Meta Bug Bounty program requires a Facebook account:

Log in or create your Meta Bug Bounty Account. Account creation requires a Facebook account.

Yeah, sorry, I'm not going to do that.

Prosody mod_pubsub_feeds

Snippet from Prosody mod_pubsub_feeds:

if method == "GET" then
  if query.node then
    if query["hub.topic"] ~= feed.url then
      module:log("debug", "Invalid topic: %s", tostring(query["hub.topic"]))
      return 404
    end
    if query["hub.mode"] == "denied" then
      module:log("info", "Subscription denied: %s", tostring(query["hub.reason"] or "No reason given"))
      feed.subscription = "denied";
      return "Ok then :(";
    elseif query["hub.mode"] == feed.subscription then
      module:log("debug", "Confirming %s request to %s", feed.subscription, feed.url)
    else
      module:log("debug", "Invalid mode: %s", tostring(query["hub.mode"]))
      return 400
    end
    local lease_seconds = tonumber(query["hub.lease_seconds"]);
    if lease_seconds then
      feed.lease_expires = time() + lease_seconds - refresh_interval * 2;
    end
    return query["hub.challenge"];
  end
  return 400
# ...

PyWa

Snippet from PyWA:

def webhook_challenge_handler(self, vt: str, ch: str) -> tuple[str, int]:
    """
    Handle the verification challenge from the webhook manually.

    - Use this function only if you are using a custom server (e.g. Django etc.).

    Args:
        vt: The verify token param (utils.HUB_VT).
        ch: The challenge param (utils.HUB_CH).

    Returns:
        A tuple containing the challenge and the status code.
    """
    if vt == self._verify_token:
        _logger.info(
            "Webhook ('%s') passed the verification challenge",
            self._webhook_endpoint,
        )
        return ch, 200
    _logger.error(
        "Webhook ('%s') failed the verification challenge. Expected verify_token: %s, received: %s",
        self._webhook_endpoint,
        self._verify_token,
        vt,
    )
    return "Error, invalid verification token", 403

mangosqueezy

Snippet from mangosqueezy:

export async function GET(request: Request) {
	const { searchParams } = new URL(request.url);
	const hubChallenge = searchParams.get("hub.challenge");
	const hubVerifyToken = searchParams.get("hub.verify_token");

	if (hubVerifyToken === process.env.INSTAGRAM_VERIFY_TOKEN) {
		return new Response(hubChallenge, {
			status: 200,
		});
	}

	return new Response("Invalid token", {
		status: 403,
	});
}

erxes

Snippet from erxes:

export const instagramSubscription = async (req, res, next) => {
  try {
    const subdomain = getSubdomain(req);
    const models = await generateModels(subdomain);

    const INSTAGRAM_VERIFY_TOKEN = await getConfig(
      models,
      'INSTAGRAM_VERIFY_TOKEN',
    );
    if (req.query['hub.mode'] === 'subscribe') {
      if (req.query['hub.verify_token'] === INSTAGRAM_VERIFY_TOKEN) {
        res.send(req.query['hub.challenge']);
      } else {
        res.send('OK');
      }
    }
  } catch (e) {
    next(e);
  }
};

  1. This may look convenient but this looks a lot like a dangerous pitfall to me. ↩︎