Is TaskPulse Task Verifier safe?

Medium risk

TaskPulse Task Verifier injects hooks on server-named domains to capture network traffic and page content on those sites.

The extension's manifest only declares access to taskpul.se, but it holds a broad <all_urls> host permission that it uses to register content scripts on domains named in a remote config it fetches from its own server. On matching sites it installs a page-level hook on XMLHttpRequest and fetch that captures every request and response (URL, method, status, and up to ~16KB of body text) and relays it back to the extension, and separately reads the page's HTML to extract an account name and ID using patterns also supplied by the server. Because the target domains and extraction patterns come from a live server response rather than the reviewed extension code, they can change at any time without a new Chrome Web Store release.

TaskPulsev2.1.0Chrome Web Store
45Risk

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

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-829
SourceAI FOUND

Server-picked sites get a network-monitoring script never listed in the manifest

Code analysis shows the service worker fetches domains from taskpul.se, then registers a content script on whichever sites the server names.

It hooks page network calls and reads the page's HTML, none of it declared in the manifest.

01EvidenceCAUSE EFFECT
What actually happens
You did this

You browse to a site while a paid TaskPulse assignment is active.

The site does not have to be taskpul.se; which sites qualify is decided by the server, not the manifest.

The extension did this

The extension registers a script on that site that hooks network calls and reads the page.

It is only able to do this because the manifest already holds the <all_urls> host permission.

02EvidenceCODE COMPARE
The code that does this

Remote config drives which sites get the network hook

What it actually does
Fetches the target list and registers the content script (functions M, N)
// Fetches the current target list from taskpul.se and stores it locally
async function syncSettings() {
  const userUuid = (await chrome.cookies.get({
    url: "https://taskpul.se/",
    name: "user_uuid"
  }))?.value ?? null;
  if (!userUuid) return;

  const settings = await fetch("https://taskpul.se/client-api/v1/getSettings", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ userUuid, extVersion: chrome.runtime.getManifest().version })
  }).then(r => r.json());

  // settings.platforms[] names the sites to inject into. None of these
  // domains appear in the extension's manifest.json.
  const platforms = settings.platforms.map(p => ({
    platformId: String(p.platformId ?? ""),
    hostPatterns: p.hostPatterns ?? [],          // e.g. {type:"brand", brand:"linkedin"}
    accountNameParsers: p.accountNameParsers ?? [],
    accountUidParsers: p.accountUidParsers ?? []
  }));

  await chrome.storage.local.set({ platforms, lastSyncAt: Date.now() });
  registerInjectionTargets(platforms);
}

// Turns the server's host patterns into match globs and registers a content
// script for them via chrome.scripting.registerContentScripts()
async function registerInjectionTargets(platforms) {
  const matches = [];
  for (const platform of platforms) {
    for (const pattern of platform.hostPatterns) {
      if (pattern.type === "subdomain_of") {
        matches.push(`https://${pattern.base}/*`, `https://*.${pattern.base}/*`);
      } else if (pattern.type === "brand") {
        matches.push(`https://${pattern.brand}.com/*`, `https://*.${pattern.brand}.com/*`);
      }
    }
  }
  await chrome.scripting.unregisterContentScripts({ ids: ["tp-social-bridge"] }).catch(() => {});
  if (matches.length === 0) return;
  await chrome.scripting.registerContentScripts([{
    id: "tp-social-bridge",
    matches: [...new Set(matches)],              // only possible because the manifest
    js: ["assets/bridge.js", "assets/injector.js"], // already holds host_permissions <all_urls>
    runAt: "document_start",
    persistAcrossSessions: true
  }]);
}
Installs the XHR/fetch hooks inside the page's own JS context
// Requested by injector.js via chrome.runtime.sendMessage({type: "tpInjectMainWorld"});
// runs with world: "MAIN", i.e. inside the page's own JavaScript context,
// not the isolated content-script world.
chrome.scripting.executeScript({
  target: { tabId },
  world: "MAIN",
  func: (captureLimit) => {
    // Waits for your first click on the page, then patches the page's own
    // XMLHttpRequest and fetch so every request/response passes through here.
    window.addEventListener("click", function onFirstClick() {
      window.removeEventListener("click", onFirstClick, true);
      installHooks();
    }, true);

    function installHooks() {
      const originalOpen = XMLHttpRequest.prototype.open;
      const originalSend = XMLHttpRequest.prototype.send;
      XMLHttpRequest.prototype.open = function (method, url, ...rest) {
        this.__tp = { method, url, startedAt: Date.now() };
        this.addEventListener("loadend", function () {
          window.postMessage({
            __tp: "injected",
            type: "capturedXMLHttpRequest",
            url: this.__tp.url,
            method: this.__tp.method,
            status: this.status,
            requestPreview: this.__tp.requestPreview,
            responsePreview: truncate(this.responseText, captureLimit)
          }, "*");
        });
        return originalOpen.apply(this, [method, url, ...rest]);
      };
      XMLHttpRequest.prototype.send = function (body) {
        if (typeof body === "string") this.__tp.requestPreview = truncate(body, captureLimit);
        return originalSend.apply(this, [body]);
      };

      const originalFetch = window.fetch;
      window.fetch = function (input, init) {
        const url = typeof input === "string" ? input : input.url;
        return originalFetch(input, init).then(async (response) => {
          const clone = response.clone();
          const contentType = clone.headers.get("content-type") || "";
          const body = /text|json|javascript|xml/.test(contentType) ? await clone.text() : null;
          window.postMessage({
            __tp: "injected",
            type: "capturedFetch",
            url,
            method: init?.method ?? "GET",
            status: response.status,
            requestPreview: typeof init?.body === "string" ? truncate(init.body, captureLimit) : null,
            responsePreview: body ? truncate(body, captureLimit) : null
          }, "*");
          return response;
        });
      };
    }
  },
  args: [captureLimit]   // 16384 characters by default
});
03EvidenceFIELD TABLE
What gets read and sent when a target site loads
FieldValueWhy it matters
Account name found on the page
Alicia GomezPulled from the page's HTML with patterns the server controls, then attached to your assignment record.
Account ID found on the page
1008452213Scraped from the same page HTML using a second server-supplied pattern, sent in the clear.
Every page request's URL and status
POST https://accounts.example.com/api/session (200)The page's own XMLHttpRequest and fetch calls are wrapped so their traffic passes through the extension.
Request/response body text
{\"status\":\"connected\",\"memberId\":\"...\"}Up to 16,384 characters of text, JSON, XML or script bodies from that traffic, captured for matching against a rule.
A hash of what was captured
fnv1a32-9c2e41abAn FNV-1a hash of the captured request/response text is sent to taskpul.se as proof a rule matched.
04EvidenceTHIRD PARTY LIST
Where the scraped data goes
  • taskpul.se

    Supplies the live list of injection targets, and receives the scraped account name/ID plus hashes of captured traffic as proof of task completion.

05EvidenceARTIFACT
Reproduce it yourself

Computes the same hash the extension sends to taskpul.se as proof of a captured request or response, so you can check what a given text hashes to.

RequiresNode.js 14+
fnv1a32-verify.js · js
#!/usr/bin/env node
// Reproduces the FNV-1a (32-bit) hash TaskPulse computes over captured
// request/response text before sending it to taskpul.se as proof of a match
// (sw.ts-CSWkVvqU.js function p, lines 71-76).
//
// Usage: node fnv1a32-verify.js "some captured text"

function fnv1a32(str) {
  let hash = 2166136261;
  for (let i = 0; i < str.length; i += 1) {
    hash ^= str.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  return `fnv1a32-${(hash >>> 0).toString(16).padStart(8, "0")}`;
}

const input = process.argv[2];
if (!input) {
  console.error('Usage: node fnv1a32-verify.js "text to hash"');
  process.exit(1);
}
console.log(fnv1a32(input));
How to run it
  1. 1
    node fnv1a32-verify.js "text to hash"
06EvidencePLAIN NOTE
Observation

Static analysis finding. This behaviour was identified by reading the shipped extension code and has not yet been reproduced in a live run. The trigger conditions and the exact data sent are read from the code, not from an observed capture.

Data recipients

taskpul.se
Updated 20 September 2026akeeedmoldecojclckdnbaadggecclcf