Is Streak CRM for Gmail safe?

Medium risk

Streak is medium risk. When email tracking is enabled for a sent Gmail message, Streak adds a zero-size image to the outgoing HTML. The URL points to mailfoogae.appspot.com/t and includes the sender email base64-encoded with an 'a' prefix, plus a message GUID.…

Streak CRMv7.110Chrome Web Store
45Risk
Who publishes it

Streak - 3 other listings from the same operator, none carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
Streak CRM
Declared legal entity
Streak
Registered address
2261 Market St #4067, San Francisco, CA 94114-1612, US

Same store account

3 other listings published from this account, 93k+ users between them, none of them carrying a finding.

Shared hosts - 2 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.

js.userflow.com
Also called by 3 other listings, including Userflow
userflow.com
Also called by 3 other listings, including Userflow

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

Publishers can request a review.

Findings

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-359
SourceAI SANDBOX

Tracking Pixel URL Encodes the Sender Email

When email tracking is enabled for a sent Gmail message, Streak adds a zero-size image to the outgoing HTML.

The URL points to mailfoogae.appspot.com/t and includes the sender email base64-encoded with an 'a' prefix, plus a message GUID.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You send a Gmail message with Streak tracking enabled.

The extension did this

The extension adds a zero-size tracking image whose URL includes your encoded sender email and a message GUID.

02EvidenceFIELD TABLE
Tracking pixel URL fields
FieldValueWhy it matters
Sender email
aY293YW5Ac3RyZWFrLmNvbQ%3D%3DIdentifies you as the Streak account that sent the tracked email.
Tracking type
zerocontentMarks the image request as the zero-content tracking pixel for the message.
Message GUID
85fe5cbf-f8b4-468b-84fa-e47303991f07Lets the service associate the image request with a specific tracked email.
03EvidenceNETWORK CAPTURE
Captured request
GEThttps://mailfoogae.appspot.com/t?sender=aY293YW5Ac3RyZWFrLmNvbQ%3D%3D&type=zerocontent&guid=85fe5cbf-f8b4-468b-84fa-e47303991f07
The shipped code constructs this image request; no request body is used for the GET pixel.
04EvidenceOPAQUE REVEAL
Why you can't catch this in DevTools

The sender field is URL-encoded base64 with a leading 'a' marker.

What's actually being sent
cowan@streak.com
05EvidenceCODE COMPARE
The code that does this

The compose hook inserts the pixel and the generator builds the URL

What it actually does
Request modifier injects tracking HTML before sendclientjs/app.e986eafa9fdcbe8f6a97.js
      composeView.registerRequestModifier(async composeParams => {
        wasRequestModifierCallbackCalled = true;
        isPlainText = composeWindowViewController.isPlainText();
        shouldTrack = this.#shouldTrack();

        // Check if the sdk given isPlainText agrees with our isPlainText
        if (composeParams.isPlainText != null && composeParams.isPlainText !== isPlainText) {
          track('sdk isPlainText does not agree with our isPlainText', {
            draftSaved
          });
        }
        if (shouldTrack && isPlainText && !shouldShowPlainTextModal) {
          track('plain text check was wrong', {
            draftSaved
          });
        }
        if (shouldTrack && !isPlainText) {
          // get encodedUrl from server and update composeParams.body for all links that should be tracked
          const trackingLinks = this.#getLinksForTracking(composeView);
          const encodedUrlFetchPromises = trackingLinks.map(link => {
            const linkHref = link.getAttribute('href');
            return getEncodedUrl(linkHref);
          });
          try {
            const encodedHrefResponses = await Promise.all(encodedUrlFetchPromises);

            // not track all links if we detect one link isn't safe
            if (!encodedHrefResponses.some(response => response.threatStatus === 'THREAT_FOUND')) {
              trackingLinks.forEach((link, index) => {
                const value = encodedHrefResponses[index];
                if (value?.linkHref === link.getAttribute('href') && value?.linkHref !== value?.encodedHref && (0,_linkTrackingValidation__WEBPACK_IMPORTED_MODULE_20__/* .isLinkCandidateToTrack */ .s2)(link)) {
                  composeParams.body = (0,_linkTrackingReplacementHelpers__WEBPACK_IMPORTED_MODULE_21__/* .replaceLink */ .t)(link, value.encodedHref, composeParams.body);
                  weakMapOfUrlsToTrackedUrls.set(link, value.encodedHref);
                }
              });
              // clean out the tracking hspace
              composeParams.body = (0,_linkTrackingReplacementHelpers__WEBPACK_IMPORTED_MODULE_21__/* .removeHspaceTrackingInfo */ .U)(composeParams.body);
            }
          } catch (error) {
            // Too long regexes trigger new sentry errors for every error instance otherwise.
            if (error instanceof SyntaxError) {
              (0,_lib_log_error__WEBPACK_IMPORTED_MODULE_7__/* ["default"] */ .Ay)({
                details: {
                  message: error.message
                },
                err: new Error(`Compose regex ${error.name}`, {
                  cause: error
                }),
                level: 'warning'
              });
            } else {
              (0,_lib_log_error__WEBPACK_IMPORTED_MODULE_7__/* ["default"] */ .Ay)({
                err: error,
                level: 'error'
              });
            }
          }
          const {
            guid,
            body
          } = _services_pixelTracking_pixelTrackingHTMLGenerator__WEBPACK_IMPORTED_MODULE_14__["default"].injectTrackingHTML(composeParams.body);
          trackedLinkData.guid = guid;
          const allRecipients = lodash__WEBPACK_IMPORTED_MODULE_0___default().uniqBy(composeView.getToRecipients().concat(composeView.getCcRecipients()).concat(composeView.getBccRecipients())
          // TODO can we make use of contacts without names? We're
          // defensively filtering them out for now.
          .filter(contact => contact.name != null), contact => contact.emailAddress);
          trackedLinkData.names = allRecipients.map(contact => contact.name);
          trackedLinkData.emails = allRecipients.map(contact => contact.emailAddress);
          trackedLinkData.subject = composeView.getSubject();
          trackedLinkData.snippetKeyList = this.#getUsedSnippetKeys();
          composeParams.body = body;
        }
        return composeParams;
      });
PixelTrackingHTMLGenerator encodes the sender and builds the img srcclientjs/app.e986eafa9fdcbe8f6a97.js
class PixelTrackingHTMLGenerator {
  /**
   * Returns the GUID for the image, returns cached version if it.
   * exists Otherwise it's generated from a very simple algorithm.
   *
   * @return {String}, GUID in string version.
   */
  getGuid() {
    // GUID doens't exist yet, so we generate a unique one from
    // a very simple pattern:
    return (0,uuid__WEBPACK_IMPORTED_MODULE_2__/* ["default"] */ .A)();
  }
  injectTrackingHTML(body) {
    const guid = this.getGuid();
    const trackingHTML = this.getTrackingHTML(guid);
    const jqDiv = jquery__WEBPACK_IMPORTED_MODULE_1___default()(document.createElement('div'));
    jqDiv[0].innerHTML = body;
    jqDiv.append(trackingHTML);
    return {
      guid,
      body: jqDiv.html()
    };
  }
  getTrackingHTML(guid) {
    if (!guid) {
      guid = this.getGuid();
    }
    return ['<div hspace="streak-pt-mark" style="max-height:1px;">', this._getImage(guid), this._getSearchMarker(), '</div>'].join('');
  }
  obfuscateEmailAddress(email) {
    return 'a' + buffer___WEBPACK_IMPORTED_MODULE_3__/* .Buffer */ .hp.from(email).toString('base64');
  }
  getObfuscatedEmailAddress() {
    return this.obfuscateEmailAddress((0,_streakyc_common_services_data_user__WEBPACK_IMPORTED_MODULE_0__/* .getUser */ .w)().getEmail());
  }
  getEncodedEmailAddress() {
    return encodeURIComponent(this.getObfuscatedEmailAddress());
  }

  /**
   * Returns the full image string.
   *
   * @return {String}, string version of the image with GUID.
   */
  _getImage(guid) {
    return '<img alt="" style="width:0px;max-height:0px;overflow:hidden" src=\'' + "https://mailfoogae.appspot.com" + '/t?sender=' + this.getEncodedEmailAddress() + '&type=zerocontent&guid=' + guid + "'/>";
  }
  _getSearchMarker() {
    return '<font color="#ffffff" size="1">' + _core_routing__WEBPACK_IMPORTED_MODULE_4__/* .StreakSearchQuery */ .jZ.PIXEL_TRACKER_DOT + '</font>';
  }
}
const pixelTrackingHTMLGeneratorSingleton = new PixelTrackingHTMLGenerator();
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (pixelTrackingHTMLGeneratorSingleton);
06EvidenceTHIRD PARTY LIST
Destination host
  • mailfoogae.appspot.com

    Receives the email-open tracking image request generated by Streak CRM for Gmail.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

LinkedIn Profile Details Sent to Streak Contact API

After the LinkedIn permission is granted and you use Streak's LinkedIn integration, the content script reads the profile name, derives the handle from the URL path, and submits both to Streak's contact API for creation or lookup.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You use Streak's LinkedIn integration on a LinkedIn profile page.

The extension did this

The content script reads the visible profile name, prepares contact fields, and sends them to Streak.

02EvidenceFIELD TABLE
Contact fields prepared from the LinkedIn page
FieldValueWhy it matters
Profile full name
Ada Lovelace (illustrative)Identifies the person whose LinkedIn profile you are viewing.
Given name
Ada (illustrative)Adds the first-name portion of the viewed profile to the contact record.
Family name
Lovelace (illustrative)Adds the last-name portion of the viewed profile to the contact record.
LinkedIn handle
in/ada-lovelace (illustrative)Links the contact record to the LinkedIn profile you opened.
03EvidenceNETWORK CAPTURE
Captured request
POSThttps://api.streak.com/api/v2/teams/KEY/contacts/
The shipped code posts contactPayload through the Streak API requester; no captured request body was recorded.
04EvidenceCODE COMPARE
The code that does this

The LinkedIn integration registers, reads the profile, and posts contact fields

What it actually does
Optional LinkedIn content script registrationbackground-mv3.js
const LINKEDIN_ORIGINS = '*://*.linkedin.com/*';
const LINKEDIN_PERMISSION = {
  origins: [LINKEDIN_ORIGINS],
};

/**
 * This array contains content scripts that are registered conditionally based on permissions granted by the user.
 */
const OPTIONAL_CONTENT_SCRIPTS = [
  {
    id: 'linkedin-content-script',
    permission: LINKEDIN_PERMISSION,
    registerOptions: {
      // Payload should follow the `chrome.scripting.registerContentScripts()` API
      matches: [LINKEDIN_ORIGINS],
      js: ['app-mv3.js'],
      runAt: 'document_start',
    },
  },
];
Profile-name extraction from the LinkedIn DOMclientjs/linkedinApp.adb1b559b4774c7153b7.js
function getProfileUserPanelElement() {
  // Old LinkedIn UI selectors
  const oldPanel = document.querySelector('#profile-content section.artdeco-card[data-member-id], [data-view-name="profile-top-card-verified-badge"]');
  if (oldPanel) {
    return oldPanel;
  }
  // Current LinkedIn UI: profile name heading is inside a section in main
  const nameHeading = document.querySelector('main section h1') ?? document.querySelector('main section h2');
  if (nameHeading) {
    return nameHeading.closest('section');
  }
  return null;
}
function getProfileFullNameElement(containerElement) {
  const container = containerElement ?? getProfileUserPanelElement();
  if (!container) return null;
  return container.querySelector('h1, h2, p');
}
;// ./extensions/common/js/lib/linkedinSDK/internal/dom-driver/utils/extract-profile-user-details.ts


function extractProfileUserDetailsFromPanel(panelEl) {
  const details = {};
  if (!panelEl) return details;
  const element = getProfileFullNameElement(panelEl);
  if (!element) return details;
  const name = element.textContent?.trim();
  if (name) {
    details.fullName = name;
  }
  return details;
}
function getProfileFullNameElementWithText() {
  const panelEl = getProfileUserPanelElement();
  if (!panelEl) return null;
  const fullNameElement = getProfileFullNameElement(panelEl);
  if (!(fullNameElement instanceof HTMLElement)) return null;
  const fullName = fullNameElement.textContent?.trim();
  if (!fullName) return null;
  return fullNameElement;
}
function getProfileUserDetails() {
  return extractProfileUserDetailsFromPanel(getProfileUserPanelElement());
}
Contact payload construction from name and URL pathclientjs/linkedinApp.adb1b559b4774c7153b7.js
const getContactCreatePayload = fullName => {
  const nameParts = extractsNameParts(fullName);
  return {
    givenName: nameParts.givenName,
    familyName: nameParts.familyName,
    linkedinHandle: getCleanLinkedinHandle(window.location.pathname)
  };
};
const getCleanLinkedinHandle = pathname => {
  // Extract just the username part, ignoring additional path segments
  const match = pathname.match(/^\/?in\/([^/]+)/);
  return match ? `in/${match[1]}` : null;
};
Contact API wrapper posts JSON to the Streak endpointclientjs/linkedinApp.adb1b559b4774c7153b7.js
async function fetchApi_createContact(contactPayload) {
  const res = await linkedin_apiRequester.post(`teams/${contactPayload.teamKey}/contacts/`, {
    json: contactPayload
  }, null, {
    prefix: '/api/v2/',
    templateUrl: 'teams/KEY/contacts',
    retry: false
  }).getPromise();
  return res;
}
Button action sends the prepared fieldsclientjs/linkedinApp.adb1b559b4774c7153b7.js
  const createTeamContact = async teamKey => {
    try {
      const details = getProfileUserDetails();
      if (!details.fullName) {
        throw new Error('LinkedIn profile missing full name for team contact creation');
      }
      const payload = getContactCreatePayload(details.fullName);
      if (!payload.linkedinHandle) {
        throw new Error('Missing LinkedIn handle for contact creation');
      }
      await createContact(teamKey, {
        givenName: payload.givenName,
        familyName: payload.familyName,
        linkedinHandle: payload.linkedinHandle
      });
      track('profileDetailsButton.createTeamContact', {
        teamKey
      });
      setSentToStreak(true);
      refetchData();
    } catch (error) {
      logError({
        err: new Error('LinkedIn team contact creation failed', {
          cause: error
        }),
        details: {
          teamKey
        }
      });
    }
  };
05EvidenceTHIRD PARTY LIST
Destination host
  • api.streak.com

    Receives the LinkedIn-derived contact fields for Streak contact creation or lookup.

Updated 30 September 2026pnnfemgpilpdaojpnkjdgfgbnnjojfik