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:
- potential browser-side code injection (reflected cross site scripting) attacks;
- potential Content-Security-Policy bypass;
- more generally tricking the target web server into serving malicious resources.
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.topiccorresponds 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 :
- The attacker redirects the target user on a server he control (
https://attacker.example.com/). - This malicious server initiates a WebSub subscription on
https://subscriber.example.comto itself (using the attacker session) on the target subscriber application. and obtains a callback URI (https://subscriber.example.com/callbacks/XXX). - The attacker server redirects the target user's browser to a subscription confirmation URI containing a malicious JavaScript payload in the
hub.challengeparameter (eg.https://subscriber.example.com/callbacks/XXXX?...&hub.challenge=<script>...</script>). - 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. - The malicious payload may for example trigger requests on
https://subscriber.example.comusing the target user session (on behalf of the target user).
There are several assumptions for this to work:
- the intent verification response content type must be
text/html; - the attacker can trigger WebSub subscriptions from the target subscriber to a publisher he controls.
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.
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:
- exploiting the target user session on the target web application for accessing private data;
- exploiting the target user session for triggering actions on the user's behalf on the target web application;
- exfiltration of data stored in
localStorage,sessionStorage, etc.; - access to webcam, microphone or other features which would have been granted to the target web application;
- etc.
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:
- the target application contains a (different) XSS vulnerability (eg.
https://sub/?q=...); - this vulnerability is mitigated with a CSP rule such as
script-src 'self'; - the attacker can trigger a WebSub intent verification response.
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);
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.
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:
- using a safe content-type (such as
application/octet-stream) in the intent verification response; - disabling MIME type sniffing in the intent response (
X-Content-Type-Options: nosniff); - restricting the set of allowed values for the
hub.challengeparameter.
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
- Original WebSub specification
- Updated WebSub specification
- Latest WebSub specification
- Security advisory: Risk of reflected cross site scriping in the WebSub intent verification
- PubSubHubbub Core 0.3
- PubSubHubbub Core 0.4
- WebSub Rocks
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.WriteHeaderhas not yet been called, Write callsWriteHeader(http.StatusOK)before writing the data. If theHeaderdoes not contain aContent-Typeline,Writeadds aContent-Typeset to the result of passing the initial 512 bytes of written data toDetectContentType. 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);
}
};
This may look convenient but this looks a lot like a dangerous pitfall to me. ↩︎