Is Storylane safe?
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.
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
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.
You start a Storylane capture on a page that references resources from other origins.
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.
| Field | Value | Why it matters | |
|---|---|---|---|
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. |
| Content-Type | application/json |
| Authorization | Bearer <redacted> |
| Extension-Version | 4.0.5.5 |
The capture engine fetches resources with cookies and forwards eligible files for upload
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)
}))
}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
}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
})
}
}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)
}
}- 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.