blog.threatdive.io

Persistent XSS via WebSocket Injection in a Third-Party Widget

~5 min read 04 Feb 2026

While looking through Gifty's bug bounty program, I started poking around their embedded giftcard widget. Gifty helps businesses sell gift cards through a simple POS interface and a widget that merchants can drop directly onto their websites.

One specific feature caught my eye an announcement tool within their embedded widget. It allows merchants to show updates to their customers. To make testing easier, Gifty built a "preview mode." I noticed that this mode relies on a WebSocket connection to push announcements real-time to the widget.

Identifying the Risk

The preview feature immediately felt like an interesting place to look. Since the widget renders whatever it receives over that WebSocket connection, the source of that connection is critical. During testing, I noticed a URL parameter called gifty_ws.

Normally, when a merchant adds an announcement, Gifty’s backend sanitizes the content. But I wondered what happens if I force the widget to connect to my server instead of theirs? If the widget blindly trusts an external WebSocket, I could bypass their server-side filters entirely.

Discovery

To see how the widget handled its data, I went straight to the source code. I found two red flags in the frontend logic:

  1. Zero Origin Validation: The gifty_ws value was used directly. There was no whitelist to ensure the widget only connected to trusted Gifty domains.

  2. Sticky Sessions: The widget used sessionStorage to keep the preview mode active. The moment the widget saw that URL parameter, it saved my malicious server address. Since sessionStorage stays alive as long as the tab is open, the victim could navigate to any other page on the merchant's site containing the widget, and it would faithfully reconnect to my socket.

1// The code that ensured the WS connection was persistent
2const wsUrl = urlParams.get(PREVIEW_WS_PARAM);
3if (session && token && wsUrl) {
4    setPreviewCredentials({ session, token, wsUrl }); 
5}

So I set up a basic WebSocket server and pointed the widget to it using the gifty_ws parameter. As expected it worked immediately. The widget connected to my WebSocket Server and started listening for events as if they were coming from Gifty’s own infrastructure.

Weaponizing the announcement

To turn this WebSocket control into a functional exploit, I had to understand Gifty’s internal protocol. The widget doesn't just display a message it follows a specific two-step handshake to render content:

  1. The update event: You first send an announcement:update event containing the full structure of the announcement (titles, text, and button layouts). This "primes" the widget with the data.

  2. The show event: Once the data is loaded, you send an announcement:show command. This acts as the trigger, forcing the widget to immediately pop up the modal on the user's screen.

With the "remote control" established, I needed to execute code. Since the widget was built with Vue, standard HTML injection was off the table tags like <script> were auto-escaped.

However, I found a loophole in the button block. Gifty designed these buttons to be versatile; they weren't just for external links. Merchants could use them for internal actions, like applying a discount code automatically when a customer clicked the button.

Because of this requirement for functional flexibility, Gifty didn't strictly validate the URL protocol. They couldn't just whitelist http: or https:, as that might break their own internal action logic. This meant a javascript: URI was accepted without question. By chaining my two WebSocket events, I could force a malicious modal onto the screen that looked like a legitimate one, executing my payload the moment the user interacted with it.

Example Payload

I pushed the following JSON through my malicious WebSocket:

 1{
 2  "type": "preview",
 3  "action": "announcement:update",
 4  "payload": {
 5    "announcement": {
 6      "id": "exploit",
 7      "content": {
 8        "blocks": [
 9          { "type": "heading", "text": "Security Update Required" },
10          {
11            "type": "buttons",
12            "buttons": [{
13              "label": "Click to Verify",
14              "action": "link",
15              "url": "javascript:(function(){alert(document.domain);})();"
16            }]
17          }
18        ]
19      }
20    }
21  }
22}

Followed by the announcement:show trigger.

1{
2    "type": "preview",
3    "action": "announcement:show",
4    "payload": { "announcementId": "exploit" }
5}

Persistent XSS Screenshot

Impact & Mitigation

This was a classic supply-chain weakness. While Gifty has thousands of customers, the actual impact was limited to merchants using the specific order-widget with the announcement feature enabled.

Furthermore, websites with a strict Content Security Policy (CSP) were protected, as a good CSP would have blocked the connection to an untrusted WebSocket origin or the execution of inline JavaScript.

By sending a crafted link to a user and tricking them into interacting with the resulting modal (a "2-click" exploit), an attacker could:

  • Inject fake payment forms or instructions.
  • Manipulate the site's UI persistently within that session.

Conclusion

This bug highlights how trust in user-provided parameters (like a WebSocket URL) can bypass robust backend sanitization. It’s a reminder that security in a shared widget isn't just about the platform itself, but about every third-party site it touches.

Kudos to the Gifty team for their transparency and speed. Following this report, they not only fixed the injection but also published new documentation on implementing CSP to help their customers further harden their websites.