Is TripChipper (TravelArrow) - Travel Better safe?
TripChipper is high risk. Searching stays on Airbnb, TravelArrow reads each listing's ID, price, dates, guests, POSTing them to Whimstay via a hardcoded bearer token in the bundle. Test confirmed live auth; a lower quote triggers a deal pill via a CJ affiliate link.…
Who publishes ittravelarrowplugin - no other listings under this identity, 10 shared hostnames
travelarrowplugin - no other listings under this identity, 10 shared hostnames
What this publisher told the store about itself, and the other listings that told it the same thing.
Shared hosts - 10 hostnames
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Airbnb search data forwarded to Whimstay API via hardcoded credential
Searching stays on Airbnb, TravelArrow reads each listing's ID, price, dates, guests, POSTing them to Whimstay via a hardcoded bearer token in the bundle.
Test confirmed live auth; a lower quote triggers a deal pill via a CJ affiliate link.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You search for accommodation on Airbnb.
Any search on one of 43 Airbnb country domains (e.g. airbnb.com, airbnb.co.uk, airbnb.de) triggers the intercept.
The extension reads listing details from the search results and forwards them to a third-party pricing API.
airbnb-interceptor.js runs in the MAIN world and intercepts the Airbnb StaysSearch GraphQL response to extract and transmit per-listing data.
HTTP 200, Whimstay returns a competing nightly price and a booking URL for the same property, or apiStatus=ERROR errorCode=221009 when no match is found. The credential authenticated successfully in live testing (no 401/403 returned).
- Content-Type
- application/json
- Authorization
- Bearer eyJ0eXBlIjoiV2hpbXN0YXktdG9rZW4iLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjI3NTQyMiIsInJlcXVlc3RfaWQiOiIySGl0RFI2emROSjRBQkFVL25oOWxnPT1fI19zb3l6NXZxTXN5WG12WkxwZ0tlTkFnPT0iLCJzY29wZSI6WyJUUkFWRUxMRVIiXSwiaXNzIjoiV2hpbXN0YXktQVBJIiwic3ViIjoid2hpbXN0YXlfMkhpdERSNnpkTko0QUJBVS9uaDlsZz09XyNfc295ejV2cU1zeVhtdlpMcGdLZU5BZz09IiwiaWF0IjoxNzY3ODU2ODEwLCJleHAiOjI1NTYyNTY4MTAsImF1ZCI6Imh0dHBzOi8vd2hpbXN0YXkuY29tIn0.N7A9WH-Uhj1AThIMR7utVZZfhj8HUuckwZTcHWTbj3s
{ "check_in": "2026-07-10", "check_out": "2026-07-15", "airbnb_property_id": "45812736", "airbnb_pricing": 1250, "no_of_adults": 2, "no_of_childs": 0, "no_of_pets": 0}- Airbnb property ID45812736
Uniquely identifies the specific rental you looked at.
- Check-in date2026-07-10
The travel date you entered in the search form.
- Check-out date2026-07-15
Your departure date from the search form.
- Nightly price (Airbnb)1250
The per-night price Airbnb showed you for this listing.
- Adult guest count2
Number of adults you specified in the search.
- Child guest count0
Number of children from your search filters.
- Pet count0
Number of pets from your search filters.
Payload construction and POST, content.js lines 97349-97412
Readable reconstruction
// Hardcoded Whimstay API endpoint and 25-year bearer tokenconst WHIMSTAY_ENDPOINT = 'https://apiprodv2.whimstay.com/whimstayAPI/v1/travel-arrow/pricing';const WHIMSTAY_TOKEN = '<JWT: id=275422, scope=TRAVELLER, exp=2051-01-02>';const CJ_AFFILIATE_BASE = 'https://www.anrdoezrs.net/click-100950458-17169403';// For each Airbnb listing in search results:async function checkWhimstayPrice(listing, searchContext) { const propertyId = extractPropertyId(listing); // from GraphQL relay ID const airbnbPrice = extractNightlyPrice(listing, searchContext.checkIn, searchContext.checkOut); const payload = { check_in: searchContext.checkIn, check_out: searchContext.checkOut, airbnb_property_id: propertyId, airbnb_pricing: airbnbPrice, no_of_adults: searchContext.adults, no_of_childs: searchContext.children, no_of_pets: searchContext.pets }; const response = await fetch(WHIMSTAY_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${WHIMSTAY_TOKEN}` }, body: JSON.stringify(payload) }); const result = await response.json(); // If Whimstay price is lower, wrap their URL in a CJ affiliate link if (result.whimstay_pricing < airbnbPrice) { return { ...result, whimstay_url: `${CJ_AFFILIATE_BASE}?sid=ta-airbnb&url=${encodeURIComponent(result.whimstay_url)}` }; }}- apiprodv2.whimstay.com
Receives per-listing POSTs with Airbnb property ID, pricing, dates, guest count. Whimstay is an independent third-party rental marketplace unrelated to Airbnb.
- www.anrdoezrs.net
Commission Junction affiliate redirect domain. Clicks on an injected Whimstay deal pill route here, attributing the referral to TravelArrow publisher 100950458.
- api.travelarrow.io
TravelArrow's own backend. Receives click-event telemetry (pillShown, pillClicked, whimstayLinkClicked) tied to the injected Airbnb UI elements.
Sends a test POST to the Whimstay pricing endpoint using the hardcoded bearer token extracted from the extension bundle. Confirms the credential is live and demonstrates the exact request the extension makes per Airbnb listing.
- Node.js 18+
#!/usr/bin/env node// Reproducer: verify the hardcoded Whimstay bearer token in TravelArrow v10.0.4// Extracted from: content.js line 97308const ENDPOINT = 'https://apiprodv2.whimstay.com/whimstayAPI/v1/travel-arrow/pricing';const TOKEN = 'eyJ0eXBlIjoiV2hpbXN0YXktdG9rZW4iLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjI3NTQyMiIsInJlcXVlc3RfaWQiOiIySGl0RFI2emROSjRBQkFVL25oOWxnPT1fI19zb3l6NXZxTXN5WG12WkxwZ0tlTkFnPT0iLCJzY29wZSI6WyJUUkFWRUxMRVIiXSwiaXNzIjoiV2hpbXN0YXktQVBJIiwic3ViIjoid2hpbXN0YXlfMkhpdERSNnpkTko0QUJBVS9uaDlsZz09XyNfc295ejV2cU1zeVhtdlpMcGdLZU5BZz09IiwiaWF0IjoxNzY3ODU2ODEwLCJleHAiOjI1NTYyNTY4MTAsImF1ZCI6Imh0dHBzOi8vd2hpbXN0YXkuY29tIn0.N7A9WH-Uhj1AThIMR7utVZZfhj8HUuckwZTcHWTbj3s';// Test payload matching the schema the extension usesconst payload = { check_in: '2026-08-01', check_out: '2026-08-07', airbnb_property_id: '45812736', // replace with a real Airbnb listing ID to test matching airbnb_pricing: 1200, no_of_adults: 2, no_of_childs: 0, no_of_pets: 0};// Decode and display the token claimsconst parts = TOKEN.split('.');const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString());console.log('JWT claims:', JSON.stringify(claims, null, 2));console.log('Token expires:', new Date(claims.exp * 1000).toISOString());console.log();const resp = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${TOKEN}` }, body: JSON.stringify(payload)});console.log('HTTP status:', resp.status);const body = await resp.json();console.log('Response:', JSON.stringify(body, null, 2));// A 200 with apiStatus=ERROR (code 221009 = no mapping for property ID) still// confirms the credential authenticated — a 401/403 would indicate it had been revoked.if (resp.status === 200) { console.log('\n[CONFIRMED] Token authenticated against live Whimstay API.');} else { console.log('\n[CHECK] Unexpected status — token may have been revoked.');}- 1Requires Node.js 18+ (native fetch).
- 2Run: node check-whimstay-token.mjs.
- 3A 200 response with apiStatus=ERROR errorCode=221009 confirms the credential is live. A 401 or 403 indicates Whimstay has revoked the token.
Extension Replays Airbnb's Private API Key to Fetch Extra Search Pages
The extension's script calls Airbnb's private search API directly, with a page-read or fallback key, pulling extra listings.
Targeting Airbnb's domain, your session rides along.
It fired once, not in three earlier tests.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-829
- Source
- Dynamic sandbox
You search for a place to stay on one of 43 Airbnb country sites and move to more results.
Airbnb's own site loads and renders the first page of listings normally.
The extension's script sends an extra request straight to Airbnb's private search API, using a page-read or fallback key, and fetches more listings than Airbnb's page asked for.
Because the request goes to Airbnb's own domain, your logged-in session cookie is attached to it automatically.
Observed during dynamic analysis after clicking Airbnb's own 'Next' pagination link on a real Airbnb search; the same trigger did not produce this request in three earlier independent test sessions on different Airbnb search pages.
- Content-Type
- application/json
- X-Airbnb-API-Key
- d306zoyjsyarp7ifhu67rjxn52tv0t20
Extracting Airbnb's API key (with a hardcoded fallback) and looping extra StaysSearch requests
Readable: findApiKey()
function findApiKey() { const metaTags = document.querySelectorAll("meta"); for (const meta of metaTags) { const content = meta.getAttribute("content") || ""; const name = (meta.getAttribute("name") || "").toLowerCase(); if (name.includes("api-key") || name.includes("apikey")) return content; } const inlineScripts = document.querySelectorAll("script:not([src])"); for (const script of inlineScripts) { const match = (script.textContent || "").match(/"key"\s*:\s*"([a-z0-9]{20,})"/); if (match) return match[1]; } return "d306zoyjsyarp7ifhu67rjxn52tv0t20"; // hardcoded fallback key}Readable: pagination loop
function fetchAdditionalPages(cursor, apiConfig) { return __awaiter(this, void 0, void 0, function* () { const searchParams = new URLSearchParams(window.location.search); let nextCursor = cursor; const collected = []; const maxPages = getPageBudget(false); // 10 on first search, 6 on later ones try { for (let page = 0; page < maxPages && nextCursor && withinRateLimit(); page++) { recordRequest(); const body = buildStaysSearchBody(nextCursor, searchParams); const response = yield originalFetch(apiConfig.apiUrl, { method: "POST", headers: apiConfig.headers, // includes X-Airbnb-API-Key body: JSON.stringify(body), credentials: "same-origin" // your Airbnb session cookie rides along }); if (!response.ok) break; const json = yield response.json(); const results = json?.data?.presentation?.staysSearch?.results?.searchResults; const paginationInfo = json?.data?.presentation?.staysSearch?.results?.paginationInfo; if (Array.isArray(results) && results.length > 0) collected.push(...results); if (page === 4) { postResultsToPage([...collected], false); collected.length = 0; } nextCursor = paginationInfo?.nextPageCursor || null; if (!nextCursor) { const pageCursors = paginationInfo?.pageCursors; if (Array.isArray(pageCursors)) { for (let i = page + 2; i < pageCursors.length; i++) { if (typeof pageCursors[i] === "string" && pageCursors[i].length > 0) { nextCursor = pageCursors[i]; break; } } } } if (!nextCursor) break; } collected.length > 0 ? postResultsToPage(collected, true) : postResultsToPage([], true); } catch (e) { postResultsToPage([], true); } });}- Search locationNew York, NY, United States
The city or area you searched for on Airbnb.
- Check-in / check-out dates2026-09-10 to 2026-09-15
Your selected travel dates from the search form.
- Guest counts2 adults, 0 children, 0 pets
Number of adults, children, and pets from your search filters.
- Airbnb private API keyd306zoyjsyarp7ifhu67rjxn52tv0t20
An internal Airbnb API credential, read from the page, or a fallback value hardcoded into the extension when none is found.
- Your Airbnb sessioncredentials: 'same-origin' (session cookie attached)
Your logged-in session cookie is attached automatically, since every extra request goes to Airbnb's own domain.
This request was captured once, immediately after clicking Airbnb's own 'Next' pagination link. Three earlier independent test sessions, run against different Airbnb search pages, did not trigger it. Per the extension's own code, the extra request only fires when Airbnb's page embeds a `nextPageCursor` value or issues a first-page search request during the browsing session; when neither condition is met, no extra request is sent.
Replicates the extension's own key-extraction logic (meta tags → inline-script regex → hardcoded fallback) against a saved Airbnb page, showing whether the extension would use a page-supplied key or its baked-in fallback value.
- Node.js 18+
- npm i jsdom
#!/usr/bin/env node// Detector: replicate the extension's own findApiKey() logic against a// saved Airbnb page and report whether it would use a key found on the// page, or fall back to the value hardcoded in the extension.import { readFileSync } from "node:fs";import { JSDOM } from "jsdom";const HARDCODED_FALLBACK_KEY = "d306zoyjsyarp7ifhu67rjxn52tv0t20";function findApiKey(document) { const metaTags = document.querySelectorAll("meta"); for (const meta of metaTags) { const content = meta.getAttribute("content") || ""; const name = (meta.getAttribute("name") || "").toLowerCase(); if (name.includes("api-key") || name.includes("apikey")) return content; } const inlineScripts = document.querySelectorAll("script:not([src])"); for (const script of inlineScripts) { const match = (script.textContent || "").match(/"key"\s*:\s*"([a-z0-9]{20,})"/); if (match) return match[1]; } return HARDCODED_FALLBACK_KEY;}const path = process.argv[2];if (!path) { console.error("Usage: node check-airbnb-key-source.mjs <saved-page.html>"); process.exit(1);}const html = readFileSync(path, "utf8");const { document } = new JSDOM(html).window;const key = findApiKey(document);console.log("Extracted API key:", key);console.log( key === HARDCODED_FALLBACK_KEY ? "[FALLBACK] No key found in the page markup \u2014 the extension would use its hardcoded fallback key." : "[PAGE-SUPPLIED] A key was found in the page's meta tags or inline scripts.");- 1Save an Airbnb search results page as HTML (browser 'Save Page As' or curl the page URL).
- 2Install the one dependency: npm i jsdom.
- 3Run: node check-airbnb-key-source.mjs saved-airbnb-page.html
Cross-Site Checkout Tracker Reports Merchant And Payment URLs To TravelArrow
TripChipper registers webRequest for every load on every site.
Visiting a cashback merchant then checkout on any of 55+ hardcoded processors sent the merchant, prior page, and checkout URL to api.travelarrow.io.
Undisclosed in the listing.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You browse a shopping or travel site the extension treats as a cashback partner, then continue on to a payment provider's hosted checkout page.
The tracker inspects the destination host of every top-level page load in the browser, not only pages on cashback-partner sites.
The extension's background service worker reports the merchant site and the checkout destination to TravelArrow's server.
It registers Chrome's webRequest API for every website (host permissions cover http://*/* and https://*/*) to watch main-frame page loads across the whole browser.
Observed during dynamic analysis: navigating to www.target.com (a live-synced cashback-domain-list merchant) followed by checkout.stripe.com (a hardcoded hosted-checkout payment host) produced 3 POST requests from the background service worker to api.travelarrow.io/v3/events within seconds, decoding to the fields shown above.
- Content-Type
- application/x-www-form-urlencoded
{ "group": "cashback", "type": "HostedCheckoutOffDomainDiscovered", "service": "target.com", "url": "https://checkout.stripe.com/", "meta": { "merchantDomain": "target.com", "fromUrl": "https://www.target.com/", "toUrl": "https://checkout.stripe.com/", "toHost": "checkout.stripe.com", "matchedKeyword": "checkout", "isKnownPaymentHost": true }}Registering a browser-wide webRequest listener and reporting matched merchant→checkout transitions
Readable: register()
class HostedCheckoutTracker { static register() { if (this.registered) return; this.registered = true; this.refreshConfigCache(); this.warmDomainsCache(); this.warmHeuristicsCache(); const filter = { urls: ["<all_urls>"], types: ["main_frame"] }; chrome.webRequest.onBeforeRequest.addListener(details => { this.isEnabledSync() && this.handleBeforeRequest(details); }, filter); chrome.webRequest.onBeforeRedirect.addListener(details => { this.isEnabledSync() && this.handleBeforeRedirect(details); }, filter); chrome.webRequest.onCompleted.addListener(details => { this.isEnabledSync() && this.handleCompleted(details); }, filter); chrome.webRequest.onErrorOccurred.addListener(details => { this.isEnabledSync() && this.chains.delete(details.requestId); }, filter); log("[HostedCheckoutTracker] webRequest listeners registered"); }}Readable: fireOffDomainDiscovery() → createEvent()
static async fireOffDomainDiscovery(match) { const dedupeKey = `${match.tabId}|${normalizeHostForDedupe(match.offHost)}|${match.matchedKeyword}`; const lastFired = this.recentDiscoveryKeys.get(dedupeKey); if (lastFired && Date.now() - lastFired < 30_000) return; // 30s de-dupe this.recentDiscoveryKeys.set(dedupeKey, Date.now()); log(`[HostedCheckoutTracker] Discovery: ${match.merchantDomain} -> ${match.offHost}`); await createEvent({ group: "cashback", type: "HostedCheckoutOffDomainDiscovered", service: match.merchantDomain, url: match.offUrl, important: true, meta: { merchantDomain: match.merchantDomain, fromUrl: match.merchantUrl, toUrl: match.offUrl, toHost: match.offHost, matchedKeyword: match.matchedKeyword, isKnownPaymentHost: match.isKnownPaymentHost } });}// createEvent() — base64-encodes the JSON and POSTs it as a single form fieldasync function createEvent(event) { const account = await getAccount(); const payload = { ...event, accountId: account?.id ?? null, meta: { ...event.meta, version: chrome.runtime.getManifest().version, name: "chromeDesktop" } }; const encoded = btoa(new TextEncoder().encode(JSON.stringify(payload)) .reduce((s, b) => s + String.fromCodePoint(b), "")); await fetch(`${config.api.url}/v3/events`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ payload: encoded }), credentials: "omit" });}- Site you were shopping ontarget.com
The merchant or travel site the extension recognized from its cashback-partner list.
- Page you lefthttps://www.target.com/
The exact page on that site you navigated away from.
- Checkout page you reachedhttps://checkout.stripe.com/
The hosted checkout page you landed on after leaving the merchant site.
- Payment processorcheckout.stripe.com
The hostname of the checkout page, matched against a built-in list of 55+ known payment processors.
- Trigger keywordcheckout
The word in the URL or page that told the extension this was a checkout flow.
- Known-processor flagtrue
Whether the destination matched the extension's built-in payment-host list, as opposed to an unrecognized checkout page.
- api.travelarrow.io
TravelArrow's own backend. Receives the merchant site, the page left, and the checkout URL for every matched navigation, as a base64-encoded event.