Is TripChipper (TravelArrow) - Travel Better safe?

High risk

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.…

travelarrowpluginv10.2.0Chrome Web Store
77Risk
Who publishes it

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.

Publisher
travelarrowplugin

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.

cdn.travelarrow.io
Also called by 1 other listing: legrooms+-for-google-flig
help.marriott.com
Also called by 1 other listing: legrooms+-for-google-flig
hotels.travelarrow.io
Also called by 1 other listing: legrooms+-for-google-flig
roomunlocker.com
Also called by 1 other listing: legrooms+-for-google-flig
seatmaps.com
Also called by 1 other listing
api.travelarrow.io
Also called by 2 other listings
carsandstays.delta
Also called by 3 other listings, including Karma
choicehotels.com
Also called by 3 other listings, including Guestbook Extension
travelarrow.io
Also called by 3 other listings, including Airbnb for Digital Nomads
vacations.alaskaair
Also called by 3 other listings, including Karma

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
What actually happens
You did this

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 did this

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.

Captured request
POSThttps://apiprodv2.whimstay.com/whimstayAPI/v1/travel-arrow/pricing

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).

Headers
Content-Type
application/json
Authorization
Bearer eyJ0eXBlIjoiV2hpbXN0YXktdG9rZW4iLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjI3NTQyMiIsInJlcXVlc3RfaWQiOiIySGl0RFI2emROSjRBQkFVL25oOWxnPT1fI19zb3l6NXZxTXN5WG12WkxwZ0tlTkFnPT0iLCJzY29wZSI6WyJUUkFWRUxMRVIiXSwiaXNzIjoiV2hpbXN0YXktQVBJIiwic3ViIjoid2hpbXN0YXlfMkhpdERSNnpkTko0QUJBVS9uaDlsZz09XyNfc295ejV2cU1zeVhtdlpMcGdLZU5BZz09IiwiaWF0IjoxNzY3ODU2ODEwLCJleHAiOjI1NTYyNTY4MTAsImF1ZCI6Imh0dHBzOi8vd2hpbXN0YXkuY29tIn0.N7A9WH-Uhj1AThIMR7utVZZfhj8HUuckwZTcHWTbj3s
Body
{  "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}
Fields sent to Whimstay per listing
  • Airbnb property ID
    45812736

    Uniquely identifies the specific rental you looked at.

  • Check-in date
    2026-07-10

    The travel date you entered in the search form.

  • Check-out date
    2026-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 count
    2

    Number of adults you specified in the search.

  • Child guest count
    0

    Number of children from your search filters.

  • Pet count
    0

    Number of pets from your search filters.

The code that does this

Payload construction and POST, content.js lines 97349-97412

Readable version

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)}` };  }}
Domains receiving Airbnb search data or affiliate credits
    • 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.

Reproduce it yourself

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.

Requires
  • Node.js 18+
check-whimstay-token.mjs · js
#!/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.');}
How to run it
  1. 1Requires Node.js 18+ (native fetch).
  2. 2Run: node check-whimstay-token.mjs.
  3. 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
What actually happens
You did this

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 did this

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.

Captured request
POSThttps://www.airbnb.co.uk/api/v3/StaysSearch/8a1bd4ac452626f1d53da40ce860330568954e2018a75773dd84dcc8dade565b

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.

Headers
Content-Type
application/json
X-Airbnb-API-Key
d306zoyjsyarp7ifhu67rjxn52tv0t20
The code that does this

Extracting Airbnb's API key (with a hardcoded fallback) and looping extra StaysSearch requests

Readable version

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);    }  });}
Fields sent with each auto-fetched search page
  • Search location
    New York, NY, United States

    The city or area you searched for on Airbnb.

  • Check-in / check-out dates
    2026-09-10 to 2026-09-15

    Your selected travel dates from the search form.

  • Guest counts
    2 adults, 0 children, 0 pets

    Number of adults, children, and pets from your search filters.

  • Airbnb private API key
    d306zoyjsyarp7ifhu67rjxn52tv0t20

    An internal Airbnb API credential, read from the page, or a fallback value hardcoded into the extension when none is found.

  • Your Airbnb session
    credentials: 'same-origin' (session cookie attached)

    Your logged-in session cookie is attached automatically, since every extra request goes to Airbnb's own domain.

What We Did and Didn't Observe

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.

Check if you're affected

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.

Requires
  • Node.js 18+
  • npm i jsdom
check-airbnb-key-source.mjs · js
#!/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.");
How to run it
  1. 1Save an Airbnb search results page as HTML (browser 'Save Page As' or curl the page URL).
  2. 2Install the one dependency: npm i jsdom.
  3. 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
What actually happens
You did this

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 did this

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.

Captured request
POSThttps://api.travelarrow.io/v3/events

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.

Headers
Content-Type
application/x-www-form-urlencoded
Body
{  "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  }}
The code that does this

Registering a browser-wide webRequest listener and reporting matched merchant→checkout transitions

Readable version

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"  });}
Fields sent to TravelArrow per matched merchant → checkout transition
  • Site you were shopping on
    target.com

    The merchant or travel site the extension recognized from its cashback-partner list.

  • Page you left
    https://www.target.com/

    The exact page on that site you navigated away from.

  • Checkout page you reached
    https://checkout.stripe.com/

    The hosted checkout page you landed on after leaving the merchant site.

  • Payment processor
    checkout.stripe.com

    The hostname of the checkout page, matched against a built-in list of 55+ known payment processors.

  • Trigger keyword
    checkout

    The word in the URL or page that told the extension this was a checkout flow.

  • Known-processor flag
    true

    Whether the destination matched the extension's built-in payment-host list, as opposed to an unrecognized checkout page.

Where the merchant + checkout navigation data ends up
    • 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.

Updated 30 September 2026coplmfnphahpcknbchcehdikbdieognn