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.1.0Chrome Web Store
75Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityHIGH
ClassMALICIOUS
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceNETWORK CAPTURE
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-Typeapplication/json
AuthorizationBearer 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
}
03EvidenceFIELD TABLE
Fields sent to Whimstay per listing
FieldValueWhy it matters
Airbnb property ID
45812736Uniquely identifies the specific rental you looked at.
Check-in date
2026-07-10The travel date you entered in the search form.
Check-out date
2026-07-15Your departure date from the search form.
Nightly price (Airbnb)
1250The per-night price Airbnb showed you for this listing.
Adult guest count
2Number of adults you specified in the search.
Child guest count
0Number of children from your search filters.
Pet count
0Number of pets from your search filters.
04EvidenceCODE COMPARE
The code that does this

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

What it actually does
Readable reconstruction
// Hardcoded Whimstay API endpoint and 25-year bearer token
const 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)}` };
  }
}
05EvidenceTHIRD PARTY LIST
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.

06EvidenceARTIFACT
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.

RequiresNode.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 97308

const ENDPOINT = 'https://apiprodv2.whimstay.com/whimstayAPI/v1/travel-arrow/pricing';
const TOKEN = 'eyJ0eXBlIjoiV2hpbXN0YXktdG9rZW4iLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjI3NTQyMiIsInJlcXVlc3RfaWQiOiIySGl0RFI2emROSjRBQkFVL25oOWxnPT1fI19zb3l6NXZxTXN5WG12WkxwZ0tlTkFnPT0iLCJzY29wZSI6WyJUUkFWRUxMRVIiXSwiaXNzIjoiV2hpbXN0YXktQVBJIiwic3ViIjoid2hpbXN0YXlfMkhpdERSNnpkTko0QUJBVS9uaDlsZz09XyNfc295ejV2cU1zeVhtdlpMcGdLZU5BZz09IiwiaWF0IjoxNzY3ODU2ODEwLCJleHAiOjI1NTYyNTY4MTAsImF1ZCI6Imh0dHBzOi8vd2hpbXN0YXkuY29tIn0.N7A9WH-Uhj1AThIMR7utVZZfhj8HUuckwZTcHWTbj3s';

// Test payload matching the schema the extension uses
const 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 claims
const 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. 1
    Requires Node.js 18+ (native fetch).
  2. 2
    Run: node check-whimstay-token.mjs.
  3. 3
    A 200 response with apiStatus=ERROR errorCode=221009 confirms the credential is live. A 401 or 403 indicates Whimstay has revoked the token.
SeverityMEDIUM
ClassMALICIOUS
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceNETWORK CAPTURE
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-Typeapplication/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
  }
}
03EvidenceCODE COMPARE
The code that does this

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

What it actually does
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 field
async 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"
  });
}
04EvidenceFIELD TABLE
Fields sent to TravelArrow per matched merchant → checkout transition
FieldValueWhy it matters
Site you were shopping on
target.comThe 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.comThe hostname of the checkout page, matched against a built-in list of 55+ known payment processors.
Trigger keyword
checkoutThe word in the URL or page that told the extension this was a checkout flow.
Known-processor flag
trueWhether the destination matched the extension's built-in payment-host list, as opposed to an unrecognized checkout page.
05EvidenceTHIRD PARTY LIST
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 10 September 2026coplmfnphahpcknbchcehdikbdieognn