Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ A community gallery for sharing single-card presets and adopting others' designs

**Currently implemented.** 28 SVG card endpoints (`/api/*`), 17 built-in themes plus gist-hosted custom palettes via `theme_url=`, five bundled variable fonts, `/api/stack` composition with namespaced child IDs, a live playground at [profilekit.vercel.app](https://profilekit.vercel.app), and an MCP wrapper at [`@heznpc/profilekit-mcp`](https://www.npmjs.com/package/@heznpc/profilekit-mcp). Zero runtime dependencies, 30-minute CDN cache, deployed on Vercel.

**Planned.** A single-card preset gallery at `/gallery` — adopt someone else's design URL as a starting point, then tweak parameters in the editor. Cross-agent preset compile (one preset → Claude Code, Cursor, Codex CLI configs). `theme_url=` adoption across the rest of the catalog.
**Planned.** A single-card preset gallery at `/gallery` — adopt someone else's design URL as a starting point, then tweak parameters in the editor. Cross-agent preset compile (one preset → Claude Code, Cursor, Codex CLI configs).

**Design intent.** *No ranking, composable presentation.* Each card is a parameter-only URL — every visual property exposed as a query string so the same endpoint renders in a GitHub README, a dev.to bio, a Hashnode header, or a slide cover with no template forking. The gallery is for *adoption*, not voting: you start from someone else's preset and edit it; we do not show which preset is "most popular." Pure SVG with CSS / SMIL keeps animations alive inside GitHub's image proxy and removes the JavaScript attack surface.

Expand Down Expand Up @@ -191,7 +191,7 @@ ProfileKit cards are plain SVG. They render anywhere a platform allows external
| `/api/timeline` | Vertical timeline |
| `/api/tags` | Tag cloud / skill pills |
| `/api/toc` | Table of contents |
| `/api/posts` | Latest posts from dev.to / Hashnode / RSS |
| `/api/posts` | Latest posts from dev.to / Medium / RSS (Hashnode via its RSS feed) |
| **Animations** | |
| `/api/typing` | Typewriter text |
| `/api/wave` | Layered animated sin waves |
Expand Down Expand Up @@ -277,7 +277,7 @@ The JSON shape mirrors the entries in `src/common/themes.js`:
- Responses are cached for 30 minutes per URL.
- On any failure (host not allowed, network, schema mismatch) the card falls back to the default `dark` palette and the response carries an `X-Theme-Error` header explaining why.

**Currently supported by**: `/api/stats`, `/api/stack` (and any cards rendered through `/api/stack`). Other endpoints will adopt `theme_url=` as a follow-up — no behavior change for callers that don't use the parameter.
**Currently supported by**: every card endpoint — `?theme_url=` is parsed by the shared option resolver (`src/common/options.js`), so `/api/stats`, `/api/hero`, `/api/posts`, `/api/stack`, and the rest all accept it. Cards rendered through `/api/stack` inherit the resolved palette.

## Common Options

Expand Down Expand Up @@ -468,11 +468,13 @@ Pick a card from the sidebar, tweak parameters in the right panel, copy the URL
#### `/api/posts`
| Param | Description |
|-------|-------------|
| `source` | `devto` (default) / `hashnode` / `medium` / `rss` |
| `username` | Author username (devto / hashnode / medium) |
| `source` | `devto` (default) / `medium` / `rss` |
| `username` | Author username (devto / medium) |
| `url` | Feed URL (rss source) |
| `count` | Number of posts (default 5, max 10) |

> Note: `source=hashnode` was retired in 2026-05 when Hashnode moved their GraphQL API behind a Pro-tier auth wall. Use `source=rss&url=https://<your>.hashnode.dev/rss.xml` instead — the RSS path still works without auth.

### Animations

#### `/api/typing`
Expand Down
35 changes: 25 additions & 10 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,28 @@
## Reporting a vulnerability

ProfileKit is a serverless SVG endpoint service that fetches data from
external URLs on behalf of users. The two user-controlled URL surfaces are:
external URLs on behalf of users. The user-controlled URL surfaces are:

- `?theme_url=` on `/api/stats` and `/api/stack` — see [`src/common/theme-url.js`](src/common/theme-url.js)
- `?source=rss|medium&url=` on `/api/posts` — see [`src/fetchers/posts.js`](src/fetchers/posts.js)
- `?theme_url=` on **every card endpoint** — parsed by the shared option
resolver in [`src/common/options.js`](src/common/options.js); the
underlying fetch lives in [`src/common/theme-url.js`](src/common/theme-url.js)
with a single-host allowlist (`gist.githubusercontent.com`), a 5-second
timeout that covers the body read, a 256 KB streaming byte cap, and
`redirect: "error"`.
- `?source=rss|medium&url=` on `/api/posts` — see
[`src/fetchers/posts.js`](src/fetchers/posts.js). 13-host allowlist plus
https-only, `redirect: "error"`, 5-second timeout across the body read,
and a 2 MB streaming byte cap (the cap aborts the underlying controller
mid-stream if exceeded, so chunked / no-content-length responses cannot
OOM the function).

Both apply a host allowlist, https-only, `redirect: "error"`, a hard timeout,
and a response-body size cap. If you find a bypass — or any other issue
(prompt injection through user-controlled text, XSS in rendered SVG,
auth/scope issue with GitHub API token pool, theme schema escape) — please
report it privately.
Both fetch paths spread caller `init` BEFORE forcing `redirect: "error"`
and `signal: controller.signal`, so a future caller cannot weaken either
guard through their own `init`.

If you find a bypass — or any other issue (prompt injection through
user-controlled text, XSS in rendered SVG, auth/scope issue with GitHub
API token pool, theme schema escape) — please report it privately.

**How to report:**

Expand All @@ -29,7 +41,7 @@ Please do **not** open a public issue for security reports.
| Acknowledgement | within 7 days |
| Coordinated disclosure | up to 90 days from report |

Single-maintainer project — only the two endpoints above are committed.
Single-maintainer project — only the surfaces above are committed.
If a fix lands earlier, disclosure happens at fix time. If a fix needs
more than 90 days (e.g., upstream dependency), we coordinate a longer
window with the reporter.
Expand All @@ -44,10 +56,13 @@ Self-hosters: re-deploy from `main` after any security advisory.

## Out of scope

- Denial of service via expensive `?source=rss` feeds — the per-request
- Denial of service via expensive `?source=rss` feeds — the streaming
body cap (2 MB) and timeout (5s) bound the surface; Vercel function
duration (10s, see `vercel.json`) is the upper bound.
- Rate-limit consumption of the deployer's GitHub API token pool — public
data only, mitigated via the token pool design (`src/common/github-token.js`).
- Issues in third-party platforms (GitHub Camo proxy, Notion image proxy,
etc.) that affect how cards render — report to those platforms directly.
- `source=hashnode` on `/api/posts` — Hashnode retired the free public
GraphQL API in 2026-05; the source is now disabled at the entry point
and returns a clean "use source=rss instead" error. No SSRF surface.
4 changes: 4 additions & 0 deletions src/common/card.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ function renderCard({ width, height, title, ariaLabel, colors, hideBorder, hideT

// titleTarget is an optional data-cas-target hook for the playground
// composer's inline-edit feature. Pure HTML attribute, no visual effect.
// TODO(2nd-pass-audit-2026-05-21): wrap titleTarget in escapeHtml as
// defense-in-depth. All current callers pass the literal "username", but
// an attribute interpolation without escape is a latent XSS regression
// path if a future caller threads user input through here.
const titleAttr = titleTarget ? ` data-cas-target="${titleTarget}"` : "";
const titleMarkup = hideTitle
? ""
Expand Down
26 changes: 25 additions & 1 deletion src/common/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,21 @@ function parseCardOptions(params) {
};
}

async function resolveCardOptions(params) {
// `prefetched` lets /api/stack avoid N+1 gist fetches: stack.js resolves the
// top-level theme_url once, then passes { url, palette } (or { url, error })
// here for every child slot whose theme_url matches. A child that overrides
// with `<card>.theme_url=<different>` falls through to the live fetch path.
async function resolveCardOptions(params, prefetched = null) {
const opts = parseCardOptions(params);
const themeUrl = params.get("theme_url");
if (!themeUrl) return { opts, themeError: null };

if (prefetched && prefetched.url === themeUrl) {
if (prefetched.error) return { opts, themeError: prefetched.error };
const colors = applyOverrides(prefetched.palette, readColorOverrides(params));
return { opts: { ...opts, colors }, themeError: null };
}

try {
const externalPalette = await fetchExternalTheme(themeUrl);
// External palette becomes the base; per-param color overrides still
Expand All @@ -85,9 +95,23 @@ async function resolveCardOptions(params) {
}
}

// Single-shot prefetch for a top-level theme_url. Used by /api/stack to
// resolve the gist once and reuse for every slot via `resolveCardOptions`'s
// `prefetched` arg.
async function prefetchExternalTheme(themeUrl) {
if (!themeUrl) return null;
try {
const palette = await fetchExternalTheme(themeUrl);
return { url: themeUrl, palette };
} catch (err) {
return { url: themeUrl, error: err.message };
}
}

module.exports = {
parseCardOptions,
resolveCardOptions,
prefetchExternalTheme,
parseSearchParams,
CARD_WIDTH_MIN,
CARD_WIDTH_MAX,
Expand Down
92 changes: 74 additions & 18 deletions src/common/theme-url.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const ALLOWED_HOSTS = new Set(["gist.githubusercontent.com"]);
const REQUIRED_KEYS = ["bg", "title", "text", "muted", "icon", "border", "accentStops"];
const TTL_MS = 30 * 60 * 1000;
const FETCH_TIMEOUT_MS = 5000;
// Real theme palettes are ~500 bytes. 256 KB is a generous ceiling that still
// bounds the body buffer so a hostile or malformed gist cannot OOM the
// function via a multi-MB JSON payload.
const MAX_BODY_BYTES = 256 * 1024;
const MAX_CACHE_ENTRIES = 128;

// Insertion-ordered Map → cheapest possible LRU. On every set() we drop the
Expand Down Expand Up @@ -91,7 +95,7 @@ function validatePalette(json) {

async function fetchExternalTheme(
rawUrl,
{ now = Date.now, fetchImpl = globalThis.fetch } = {}
{ now = Date.now, fetchImpl = globalThis.fetch, timeoutMs = FETCH_TIMEOUT_MS } = {}
) {
const url = validateUrl(rawUrl);
const cached = cache.get(url.href);
Expand All @@ -100,44 +104,95 @@ async function fetchExternalTheme(
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

let res;
let palette;
try {
res = await fetchImpl(url.href, {
// The timer MUST stay alive across the body read — a drip-fed body would
// otherwise hold the connection open after the fetch promise resolved.
// Both the headers-only fetch AND the body read are inside this try;
// clearTimeout fires in the outer finally only.
const res = await fetchImpl(url.href, {
headers: { "User-Agent": "ProfileKit/1.0 (+theme_url)" },
redirect: "error",
signal: controller.signal,
});
if (!res.ok) {
throw new ThemeUrlError(`theme_url fetch failed: HTTP ${res.status}`);
}
// Pre-read body cap. Sanitized as non-negative integer so a
// `Content-Length: -1` from a buggy upstream can't bypass the check.
const declaredLen = Number(res.headers.get("content-length"));
if (Number.isInteger(declaredLen) && declaredLen >= 0 && declaredLen > MAX_BODY_BYTES) {
throw new ThemeUrlError(`theme_url payload too large: ${declaredLen} bytes`);
}
// Streaming byte counter — caps memory even for chunked / lying-content-
// length responses. Aborts mid-read once the cap is exceeded.
const text = await readBodyCapped(res, controller);
let json;
try {
json = JSON.parse(text);
} catch {
throw new ThemeUrlError("theme_url payload is not valid JSON");
}
palette = validatePalette(json);
} catch (err) {
if (err.name === "AbortError") {
// Distinguish timer-induced aborts from upstream-surfaced AbortErrors by
// consulting the controller, not just the duck-typed error name. Undici
// can surface AbortError for non-timeout reasons (mid-stream server
// reset) and we don't want to mislabel those as timeouts.
if (controller.signal.aborted && err.name === "AbortError") {
throw new ThemeUrlError("theme_url fetch timed out");
}
if (err instanceof ThemeUrlError) throw err;
throw new ThemeUrlError(`theme_url fetch failed: ${err.message}`);
} finally {
clearTimeout(timeoutId);
}

if (!res.ok) {
throw new ThemeUrlError(`theme_url fetch failed: HTTP ${res.status}`);
}

const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new ThemeUrlError("theme_url payload is not valid JSON");
}

const palette = validatePalette(json);
cache.set(url.href, { palette, expiresAt: now() + TTL_MS });
if (cache.size > MAX_CACHE_ENTRIES) {
cache.delete(cache.keys().next().value);
}
return palette;
}

// Read a Response body chunk by chunk, tracking bytes against the cap.
// Aborts the underlying controller and throws if the cap is exceeded mid-
// stream. Falls back to res.text() for mocks / older Response shapes that
// don't expose a streamable body — in that case the cap is enforced after
// the read using Buffer.byteLength (UTF-8), which is correct for bytes but
// only as good as the mock's willingness to materialize the whole body.
async function readBodyCapped(res, controller) {
if (!res.body || typeof res.body.getReader !== "function") {
const text = await res.text();
if (Buffer.byteLength(text, "utf8") > MAX_BODY_BYTES) {
throw new ThemeUrlError(`theme_url payload too large: ${Buffer.byteLength(text, "utf8")} bytes`);
}
return text;
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
const parts = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_BODY_BYTES) {
try { controller.abort(); } catch { /* ignore */ }
throw new ThemeUrlError(`theme_url payload too large: ${bytes} bytes`);
}
parts.push(decoder.decode(value, { stream: true }));
}
parts.push(decoder.decode());
return parts.join("");
} finally {
try { reader.releaseLock(); } catch { /* ignore */ }
}
}

function clearCache() {
cache.clear();
}
Expand All @@ -152,5 +207,6 @@ module.exports = {
REQUIRED_KEYS,
TTL_MS,
FETCH_TIMEOUT_MS,
MAX_BODY_BYTES,
MAX_CACHE_ENTRIES,
};
59 changes: 47 additions & 12 deletions src/endpoints/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const CARDS = {
common_params: ["theme", "width"],
},
posts: {
description: "Latest posts from devto/hashnode/medium/rss",
description: "Latest posts from devto/medium/rss (hashnode source retired 2026-05 — use rss against your Hashnode blog's /rss feed)",
required: ["source"],
common_params: ["username", "url", "count", "theme"],
},
Expand Down Expand Up @@ -160,21 +160,56 @@ const CARDS = {
},
};

// Params accepted by every card endpoint via the shared option resolver in
// src/common/options.js. Injected into each card's common_params at response
// time rather than duplicated into every CARDS entry — single edit if a new
// universal param appears.
const UNIVERSAL_PARAMS = [
"theme",
"theme_url",
"font",
"bg_color",
"text_color",
"title_color",
"icon_color",
"border_color",
"accent_color",
"hide_border",
"hide_title",
"hide_bar",
"border_radius",
"card_width",
];

function buildCatalogResponse() {
const cards = {};
for (const [name, entry] of Object.entries(CARDS)) {
// health/catalog are utility endpoints — they don't render cards, so
// they don't accept the universal card-rendering params.
if (name === "health" || name === "catalog") {
cards[name] = entry;
continue;
}
const merged = new Set([
...(entry.common_params || []),
...UNIVERSAL_PARAMS,
]);
cards[name] = { ...entry, common_params: Array.from(merged) };
}
return {
version: CATALOG_VERSION,
cards,
themes: Object.keys(themes),
};
}

module.exports = async (req, res) => {
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Cache-Control", cacheHeaders());
return res.send(
JSON.stringify(
{
version: CATALOG_VERSION,
cards: CARDS,
themes: Object.keys(themes),
},
null,
2
)
);
return res.send(JSON.stringify(buildCatalogResponse(), null, 2));
};

module.exports.CATALOG_VERSION = CATALOG_VERSION;
module.exports.CARDS = CARDS;
module.exports.UNIVERSAL_PARAMS = UNIVERSAL_PARAMS;
module.exports.buildCatalogResponse = buildCatalogResponse;
2 changes: 1 addition & 1 deletion src/endpoints/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ module.exports = async (req, res) => {
res.setHeader("Content-Type", "image/svg+xml");
if (themeError) res.setHeader("X-Theme-Error", themeError);

if ((source === "devto" || source === "hashnode") && !username) {
if (source === "devto" && !username) {
res.setHeader("Cache-Control", errorCacheHeaders("bad_input"));
return res.send(renderError("Missing ?username= parameter", { colors, font }));
}
Expand Down
Loading