Is TaskPulse Task Verifier safe?
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.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
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.
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 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.
Remote config drives which sites get the network hook
// 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
}]);
}// 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
});| Field | Value | Why it matters | |
|---|---|---|---|
Account name found on the page | Alicia Gomez | Pulled from the page's HTML with patterns the server controls, then attached to your assignment record. | |
Account ID found on the page | 1008452213 | Scraped 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-9c2e41ab | An FNV-1a hash of the captured request/response text is sent to taskpul.se as proof a rule matched. |
- 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.
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.
#!/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));
- 1node fnv1a32-verify.js "text to hash"
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.