Is Storylane safe?

Medium risk

Storylane is medium risk. Starting a Storylane capture, v4.0.5.5 re-fetches images, fonts, CSS, and SVG refs with cookies included per origin. Resources over 5 KB upload via Storylane's API when CDN upload is on. Dynamic analysis didn't reach this; code-derived.

Storylane Incv4.0.5.10Chrome Web Store
45Risk
Who publishes it

STORYLANE INC - no other listings under this identity, 1 shared hostname

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

Publisher
Storylane Inc
Declared legal entity
STORYLANE INC
Registered address
2261 Market St, #4813, San Francisco, CA 94114-1612, US
Registered contact
Nalin Pradeep

Shared hosts - 1 hostname

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.

app-eu2.hubspot.com
Also called by 2 other listings, including trellus

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

Publishers can request a review.

Findings

Storylane capture includes cookies in resource fetches

MediumunwantedCWE-200Dynamic sandbox

Starting a Storylane capture, v4.0.5.5 re-fetches images, fonts, CSS, and SVG refs with cookies included per origin.

What actually happens

You did this

You start a Storylane capture on a page that references resources from other origins.

The extension did this

The extension re-fetches those resources with cookies included and can pass the resulting files to Storylane's upload flow.

This applies to resources the capture engine inlines, including image sources, CSS URLs, CSS imports, and SVG image references.

Fields and options in the affected fetch and upload path

  • Resource URL
    https://assets.example-corp.com/secure/logo.png (illustrative)

    This is the page resource the extension re-fetches during capture, even when it belongs to a different origin than the page being captured.

  • Cookies included
    credentials: "include"

    Cookies for the resource's own site can be attached to the re-fetch, exposing cookie-protected resources to the capture flow.

  • Capture page headers
    Referer: https://app.example-corp.com/dashboard; Origin: https://app.example-corp.com (illustrative)

    The fetch options add page context headers, so the request can carry information about where the capture started.

  • Uploaded resource record
    payload: { data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", resourceURL: "https://assets.example-corp.com/secure/logo.png" } (illustrative)

    When the CDN option accepts a resource, the encoded file and original resource URL are sent to the background upload handler.

Captured request

POSThttps://api.storylane.io/api/v1/company/aws_signatures

The code expects an upload signature with a pre-signed storage URL, then performs a PUT of the resource file to that URL.

Headers
Content-Type
application/json
Authorization
Bearer <redacted>
Extension-Version
4.0.5.5

The code that does this

The capture engine fetches resources with cookies and forwards eligible files for upload

Readable version

Readable resource fetch helper

deobfuscated/htmlCaptureEngineScript.bundle.js:1076-1115
Ee = async (e, t) => {  if (e.startsWith("data:")) return e;  if (e.startsWith(chrome.runtime.getURL(""))) return "";  let n;  try {    n = await fetch(e, {      ...t,      credentials: "include"    })  } catch (n) {    return chrome.tabs ? "" : await chrome.runtime.sendMessage({      action: ve.inlineResource,      payload: {        url: e,        fetchParams: t      }    })  }  const r = await n.arrayBuffer(),    o = Le({      url: e,      responseContentType: n.headers.get("content-type"),      buffer: r    });  if (o?.startsWith("text/css")) return (new TextDecoder).decode(r);  if (!o) return console.error(`Could not get content type for: ${e}`), "";  const i = e.split("#");  if ("image/svg+xml" === o && i.length > 1) {    const e = (new TextDecoder).decode(r),      t = i[1];    return `data:image/svg+xml;base64,${btoa(Ae(e,t))}`  }  if (o.startsWith("text/html")) return "";  const a = new FileReader;  return a.readAsDataURL(new Blob([r], {    type: o  })), await new Promise(((e, t) => {    a.addEventListener("load", (() => e(a.result)), !1), a.addEventListener("error", t, !1)  }))}

Readable CDN handoff for captured images and fonts

deobfuscated/htmlCaptureEngineScript.bundle.js:1153-1191
const Fe = async function({  url: e,  captureOptions: t}) {  if (!e) return "";  const n = await Ee(e, {      headers: {        Referer: window.location.href,        Origin: window.location.origin,        "User-Agent": navigator.userAgent      }    }),    r = n.match(/^data:([a-zA-Z0-9-+/]+\/[a-zA-Z0-9-+.]+);base64,/),    o = r?.[1];  if (t?.cdnUploadFonts && ("font/otf" === o || "font/ttf" === o || "font/eot" === o || "font/woff" === o || "font/woff2" === o)) {    const {      cdnUrl: t    } = await Me({      action: ne.webCaptureUploadCdnResource,      payload: {        data: n,        resourceURL: e      }    });    return t || n  }  if (t?.cdnUploadImages && ("image/png" === o || "image/jpeg" === o || "image/avif" === o || "image/webp" === o || "image/gif" === o || "image/svg+xml" === o)) {    const {      cdnUrl: t    } = await Me({      action: ne.webCaptureUploadCdnResource,      payload: {        data: n,        resourceURL: e      }    });    return t || n  }  return n}

Readable background upload handler

deobfuscated/background.bundle.js:23008-23035
else if (e.action === Kd.webCaptureUploadCdnResource) {  const {    data: t,    resourceURL: a  } = e.payload, r = /^data:([a-zA-Z0-9-+/]+\/[a-zA-Z0-9-+.]+);base64,/, s = t.match(r), i = s?.[1];  if (!i) throw n({    cdnUrl: void 0  }), new Error("Missing content type for resource");  if ("font/woff" !== i && "font/woff2" !== i && "font/ttf" !== i && "font/otf" !== i && "image/png" !== i && "image/jpeg" !== i && "image/avif" !== i && "image/webp" !== i && "image/gif" !== i && "image/svg+xml" !== i && "application/x-font-ttf" !== i) return void n({    cdnUrl: void 0  });  if (t.length < 5120) return void n({    cdnUrl: void 0  });  try {    const e = new URL(a);    let r = await ic({      base64: t,      contentType: i    });    "image/svg+xml" === i && Boolean(e.hash) && (r += e.hash), n({      cdnUrl: r    })  } catch (e) {    n({      cdnUrl: void 0    })  }}

Readable Storylane upload signature request

deobfuscated/background.bundle.js:17155-17224,18888-18917,22745-22759
function hr(e, t = "get", n, a = {}) {  return fetch(`https://api.storylane.io/api/${e}`, {    method: t,    credentials: "include",    body: JSON.stringify(n),    ...a,    headers: {      "Content-Type": "application/json",      "Extension-Version": _r,      ...a.headers    }  }).then((e => e.ok ? e.json() : e.json().then((t => {    throw new Error(t.error.description || e.statusText)  })))).catch((e => {    if (!("object" == typeof e && e && "message" in e ? String(e.message) : "").includes("Unexpected end of JSON input")) throw console.error(e), e  }))}function pr(e, t = "get", n, a) {  return new Promise(((r, s) => {    (async function() {      return mr("https://app.storylane.io")    })().then((i => {      i ? r(hr(e, t, n, {        headers: {          Authorization: `Bearer ${i}`        },        ...a      })) : s(new Error(`User is not authenticated. Performing ${t} request to ${e}`))    }))  }))}function Lr(e) {  return pr("v1/company/aws_signatures", "post", e)}const vd = async ({  file: e,  uploadKey: t,  gzip: n}) => {  const a = await (r = {    key: t,    filename: e.name  }, $r((() => Lr(r)), cr.createAwsSignature, r));  var r;  const {    headers: s,    url: i  } = a, o = new URL(i), d = {    "Access-Control-Allow-Origin": "*",    "Content-Type": e.type,    "Cache-Control": "no-cache"  };  n && (e = await Yd(e), d["Content-Encoding"] = "gzip");  const u = await fetch(o.href, {    method: "PUT",    headers: {      ...d,      ...s    },    body: e  });  if (!u.ok) throw new Error(u.statusText);  return `${o.origin}${o.pathname}`};ic = async ({  base64: e,  contentType: t}) => {  try {    const n = await za.get(),      a = await rc(e),      r = await Ja();    if (r && r[a]) return r[a];    if (n) {      const n = (await Dd({          file: Sd(e, a + sc(t)),          uploadKey: "resources"        })).replace("storylane-prod-uploads.s3.us-east-2.amazonaws.com", "app-content.storylane.io"),        r = await Ja();      return await async function(e) {        const t = await za.get();        if (t) return za.set({          ...t,          cdnCachedResources: e        })      }({        ...r,        [a]: n      }), n    }    return  } catch (e) {    return void console.error(e)  }}

Destinations used by the upload path

    • api.storylane.io

    Storylane API host used to request upload signatures and other extension API calls.

    • storylane-prod-uploads.s3.us-east-2.amazonaws.com

    Storage host used by the pre-signed upload URL before the code rewrites returned resource URLs.

    • app-content.storylane.io

    Storylane content host returned for cached uploaded resources after the S3 hostname replacement.

Updated 30 September 2026ongmhighpnfpojfidgigcjflgdkjfdeb