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
4 changes: 3 additions & 1 deletion docs/fumadocs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"turndown": "^7.2.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/turndown": "^5.0.6",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
Expand Down
291 changes: 291 additions & 0 deletions docs/fumadocs/src/app/api/save-skill/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { NextRequest, NextResponse } from 'next/server';
import TurndownService from 'turndown';

const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Unbounded memory growth in rateLimitMap — expired entries are never purged

The module-level rateLimitMap at docs/fumadocs/src/app/api/save-skill/route.ts:4 stores a rate-limit entry per unique IP address. Entries are only overwritten when the same IP makes a new request after its window expires (line 12-13), but entries from IPs that never return are never removed.

Root Cause

The checkRateLimit function updates or creates entries in rateLimitMap on each call, but there is no cleanup mechanism (no periodic sweep, no TTL eviction, no size cap). In a production deployment behind a CDN or proxy, the x-forwarded-for header yields a unique key per client IP. Over time—especially under moderate traffic—the map accumulates entries indefinitely.

const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

Since this is a Next.js API route running in a long-lived server process (or a serverless function with warm instances), each unique IP that ever hits the endpoint leaves a permanent entry in memory.

Impact: Gradual memory leak proportional to the number of unique client IPs. Under sustained traffic this can eventually cause OOM or degraded performance.

Prompt for agents
Add a cleanup mechanism to the rateLimitMap in docs/fumadocs/src/app/api/save-skill/route.ts. One approach: at the beginning of checkRateLimit(), periodically (e.g. every 100 calls or every 60 seconds) iterate the map and delete entries whose resetTime is in the past. Alternatively, add a MAX_MAP_SIZE constant (e.g. 10000) and if the map exceeds it, sweep all expired entries. Another option is to switch to an LRU cache library with TTL support. The key change should be in the checkRateLimit function around lines 8-23.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const RATE_LIMIT_MAX_REQUESTS = 10;

function checkRateLimit(key: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const entry = rateLimitMap.get(key);

if (!entry || now > entry.resetTime) {
rateLimitMap.set(key, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 };
}

if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
return { allowed: false, remaining: 0 };
}

entry.count++;
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count };
}
Comment on lines +4 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

In-memory rate limit map leaks indefinitely and is ineffective in serverless deployments.

Two problems:

  1. Memory leak: Expired entries are never evicted. Under sustained traffic the map grows without bound.
  2. Serverless: Each cold-start instance gets a fresh map, so the rate limit is trivially bypassed on platforms like Vercel.

For a quick fix on (1), add periodic cleanup or evict stale entries inside checkRateLimit. For (2), consider an external store (e.g., Upstash Redis with sliding-window) if real rate limiting is required.

Minimal fix for the memory leak
 function checkRateLimit(key: string): { allowed: boolean; remaining: number } {
   const now = Date.now();
   const entry = rateLimitMap.get(key);

+  // Prune expired entries periodically
+  if (rateLimitMap.size > 1000) {
+    for (const [k, v] of rateLimitMap) {
+      if (now > v.resetTime) rateLimitMap.delete(k);
+    }
+  }
+
   if (!entry || now > entry.resetTime) {
🤖 Prompt for AI Agents
In `@docs/fumadocs/src/app/api/save-skill/route.ts` around lines 4 - 23, The
in-memory rate limiter leaks because expired entries in rateLimitMap are never
removed and is ineffective in serverless; update the checkRateLimit function to
evict stale entries on each call (iterate keys or check specific key and remove
when resetTime < now) using the existing RATE_LIMIT_WINDOW_MS constant to
compute expirations, and/or add a periodic cleanup routine that prunes entries
from rateLimitMap; additionally, replace or back this logic with an external
store (e.g., Upstash/Redis with a sliding-window algorithm) when deploying to
serverless to ensure global, persistent rate limiting.


const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1', '0.0.0.0']);

function isAllowedUrl(url: string): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;

const hostname = parsed.hostname.toLowerCase();
const bare = hostname.replace(/^\[|\]$/g, '');

if (BLOCKED_HOSTS.has(hostname) || BLOCKED_HOSTS.has(bare)) return false;
if (bare.startsWith('::ffff:')) return isAllowedUrl(`http://${bare.slice(7)}`);
if (/^127\./.test(bare) || /^0\./.test(bare)) return false;
if (bare.startsWith('10.') || bare.startsWith('192.168.')) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(bare)) return false;
if (bare.startsWith('169.254.')) return false;
if (bare.startsWith('fe80:') || bare.startsWith('fc00:') || bare.startsWith('fd')) return false;
if (/^(22[4-9]|23\d|24\d|25[0-5])\./.test(bare)) return false;
if (/^ff[0-9a-f]{2}:/.test(bare)) return false;
return true;
} catch {
return false;
}
}
Comment on lines +27 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SSRF protection is vulnerable to DNS rebinding.

isAllowedUrl validates the hostname string but the actual fetch on line 83 resolves DNS independently. An attacker can register a domain that first resolves to a public IP (passing validation) and then rebinds to 127.0.0.1 (or a private IP) at fetch time.

Mitigations:

  • Resolve the hostname to an IP before fetching and validate the resolved IP against the same blocklist.
  • Alternatively, use a library like ssrf-req-stream or configure a custom DNS resolver/agent that rejects private IPs.

Also, metadata.google.internal and cloud metadata endpoints (e.g., 169.254.169.254) should be explicitly blocked by hostname in addition to the IP range check on line 40.

🤖 Prompt for AI Agents
In `@docs/fumadocs/src/app/api/save-skill/route.ts` around lines 27 - 48,
isAllowedUrl currently validates only the hostname string which is vulnerable to
DNS rebinding; update the flow so that before calling fetch (the call around
line 83) you perform a DNS resolution of the provided hostname (e.g., using
dns.promises.lookup or resolve to get all IPs) and run the same IP-blocklist
checks against each resolved address (including IPv4-mapped IPv6 forms) in
addition to the existing hostname checks in isAllowedUrl; also explicitly add
cloud metadata hostnames like "metadata.google.internal" and the literal address
"169.254.169.254" to the BLOCKED_HOSTS set; alternatively, you may replace the
fetch with an agent/library (e.g., ssrf-req-stream or a custom DNS-resolving
agent) that enforces IP validation before connecting.


const GITHUB_URL_PATTERN = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/;
const GITHUB_RAW_PATTERN = /^https?:\/\/raw\.githubusercontent\.com\//;
const FETCH_TIMEOUT = 30_000;

const TECH_KEYWORDS = new Set([
'react', 'vue', 'angular', 'svelte', 'nextjs', 'nuxt', 'remix',
'typescript', 'javascript', 'python', 'rust', 'go', 'java', 'ruby',
'node', 'deno', 'bun', 'docker', 'kubernetes', 'terraform',
'aws', 'gcp', 'azure', 'vercel', 'netlify', 'cloudflare',
'graphql', 'rest', 'grpc', 'websocket', 'redis', 'postgres',
'mongodb', 'sqlite', 'mysql', 'prisma', 'drizzle',
'tailwind', 'css', 'html', 'sass', 'webpack', 'vite', 'esbuild',
'git', 'ci', 'cd', 'testing', 'security', 'authentication',
'api', 'cli', 'sdk', 'mcp', 'llm', 'ai', 'ml', 'openai', 'anthropic',
]);

const TAG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;

const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });

interface ExtractedContent {
title: string;
content: string;
sourceUrl: string;
contentType: string;
language?: string;
}

async function extractFromUrl(url: string): Promise<ExtractedContent> {
if (GITHUB_URL_PATTERN.test(url) || GITHUB_RAW_PATTERN.test(url)) {
return fetchGitHubContent(url);
}

const MAX_BODY_SIZE = 5 * 1024 * 1024;
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT) });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 SSRF bypass via HTTP redirects — fetch follows redirects without re-validating the target URL

The isAllowedUrl check at route.ts:251 validates only the user-supplied URL, but the subsequent fetch call at route.ts:84 uses the default redirect policy (redirect: 'follow'). An attacker can supply a URL like https://attacker.com/redir that passes isAllowedUrl, but have the server respond with a 302 redirect to http://169.254.169.254/latest/meta-data/ (AWS instance metadata) or any other internal/blocked address.

Root Cause and Impact

The validation at line 251 calls isAllowedUrl(url) on the original URL only. When fetch(url, ...) is called at line 84, Node's fetch implementation follows redirects by default (up to 20 hops). The redirect target is never checked against isAllowedUrl.

// route.ts:84 — no redirect restriction
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT) });

Impact: A malicious user can read responses from internal services (cloud metadata endpoints, internal APIs on private networks) by pointing the server at an attacker-controlled redirect. This is a classic SSRF vector that defeats the hostname-based blocklist.

Suggested change
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT) });
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT), redirect: 'error' });
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}

const contentLength = Number(response.headers.get('content-length') || '0');
if (contentLength > MAX_BODY_SIZE) {
throw new Error('Response too large');
}

const contentType = response.headers.get('content-type') ?? '';
const body = await response.text();
if (body.length > MAX_BODY_SIZE) {
throw new Error('Response too large');
}

if (contentType.includes('text/html')) {
const titleMatch = body.match(/<title[^>]*>([^<]+)<\/title>/i);
const title = titleMatch?.[1]?.trim() ?? new URL(url).hostname;
const bodyMatch = body.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const content = turndown.turndown(bodyMatch?.[1] ?? body);
return { title, content, sourceUrl: url, contentType: 'webpage' };
}

const title = new URL(url).pathname.split('/').pop() ?? 'Untitled';
return { title, content: body, sourceUrl: url, contentType: 'text' };
}

const LANG_MAP: Record<string, string> = {
'.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript',
'.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java',
'.kt': 'kotlin', '.swift': 'swift', '.sh': 'shell', '.yml': 'yaml', '.yaml': 'yaml',
'.json': 'json', '.md': 'markdown', '.html': 'html', '.css': 'css', '.sql': 'sql',
};

async function fetchGitHubContent(url: string): Promise<ExtractedContent> {
let rawUrl = url;
const match = url.match(GITHUB_URL_PATTERN);
if (match) {
const [, owner, repo, branch, path] = match;
rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
}

const MAX_BODY_SIZE = 5 * 1024 * 1024;
const response = await fetch(rawUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT) });
if (!response.ok) {
throw new Error(`Failed to fetch GitHub content: ${response.status} ${response.statusText}`);
}

const contentLength = Number(response.headers.get('content-length') || '0');
if (contentLength > MAX_BODY_SIZE) {
throw new Error('Response too large');
}

const body = await response.text();
if (body.length > MAX_BODY_SIZE) {
throw new Error('Response too large');
}
const filename = rawUrl.split('/').pop() ?? 'file';
const ext = filename.includes('.') ? '.' + filename.split('.').pop()!.toLowerCase() : '';
const language = LANG_MAP[ext];
const isCode = language !== undefined && language !== 'markdown';
const content = isCode ? `\`\`\`${language}\n${body}\n\`\`\`` : body;

return { title: filename, content, sourceUrl: url, contentType: 'github', language };
}

function addTag(counts: Map<string, number>, tag: string, weight: number): void {
if (TAG_PATTERN.test(tag)) {
counts.set(tag, (counts.get(tag) ?? 0) + weight);
}
}

function detectTags(extracted: ExtractedContent): string[] {
const counts = new Map<string, number>();

try {
const segments = new URL(extracted.sourceUrl).pathname
.split('/').filter(Boolean)
.map((s) => s.toLowerCase().replace(/[^a-z0-9-]/g, ''));
for (const seg of segments) {
if (seg.length >= 2 && seg.length <= 30) {
addTag(counts, seg, 2);
}
}
} catch { /* skip */ }

const headingRe = /^#{1,2}\s+(.+)$/gm;
let m: RegExpExecArray | null;
while ((m = headingRe.exec(extracted.content)) !== null) {
for (const word of m[1].toLowerCase().split(/\s+/)) {
const cleaned = word.replace(/[^a-z0-9-]/g, '');
if (cleaned.length >= 2) {
addTag(counts, cleaned, 2);
}
}
}

const codeBlockRe = /^```(\w+)/gm;
while ((m = codeBlockRe.exec(extracted.content)) !== null) {
const lang = m[1].toLowerCase();
if (lang.length >= 2) {
addTag(counts, lang, 3);
}
}

const lower = extracted.content.toLowerCase();
for (const keyword of TECH_KEYWORDS) {
if (new RegExp(`\\b${keyword}\\b`, 'i').test(lower)) {
addTag(counts, keyword, 1);
}
}

if (extracted.language) {
addTag(counts, extracted.language.toLowerCase(), 3);
}

return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([tag]) => tag);
}

function slugify(input: string): string {
const slug = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
return slug.slice(0, 64).replace(/-+$/, '') || 'untitled-skill';
}

function yamlEscape(value: string): string {
const singleLine = value.replace(/\r?\n/g, ' ').trim();
if (/[:#{}[\],&*?|>!%@`]/.test(singleLine) || singleLine.startsWith("'") || singleLine.startsWith('"')) {
return `"${singleLine.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return singleLine;
}

export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
const { allowed, remaining } = checkRateLimit(ip);

if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests. Try again in a minute.' },
{ status: 429, headers: { 'X-RateLimit-Remaining': '0', 'Retry-After': '60' } },
);
}

let body: { url?: string; name?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const { url, name } = body;
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'Missing required field: url' }, { status: 400 });
}

if (name !== undefined && typeof name !== 'string') {
return NextResponse.json({ error: 'Field "name" must be a string' }, { status: 400 });
}

if (!isAllowedUrl(url)) {
return NextResponse.json({ error: 'URL not allowed' }, { status: 403 });
}

try {
const extracted = await extractFromUrl(url);
const tags = detectTags(extracted);

const skillName = slugify(name || extracted.title || 'untitled');
const description = extracted.content
.split('\n')
.find((l) => l.trim().length > 0)
?.replace(/^#+\s*/, '')
.trim()
.slice(0, 200) || 'Saved skill';
const savedAt = new Date().toISOString();

const yamlTags = tags.length > 0
? `tags:\n${tags.map((t) => ` - ${t}`).join('\n')}\n`
: '';

const skillMd =
`---\n` +
`name: ${skillName}\n` +
`description: ${yamlEscape(description)}\n` +
yamlTags +
`metadata:\n` +
` source: ${yamlEscape(url)}\n` +
` savedAt: ${savedAt}\n` +
`---\n\n` +
extracted.content + '\n';

return NextResponse.json(
{ name: skillName, skillMd, tags, description },
{ headers: { 'X-RateLimit-Remaining': String(remaining) } },
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to extract content';
return NextResponse.json({ error: message }, { status: 502 });
}
}
23 changes: 16 additions & 7 deletions docs/skillkit/public/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,30 @@
<div class="container">
<a href="/" class="back">&larr; Back to SkillKit</a>
<h1>Privacy Policy</h1>
<p class="updated">Last updated: February 10, 2026</p>
<p class="updated">Last updated: February 14, 2026</p>

<h2>Overview</h2>
<p>SkillKit ("we", "our", "us") is an open-source CLI tool and Chrome extension for managing AI agent skills. We are committed to protecting your privacy. This policy explains what data we collect (or don't) across all SkillKit products.</p>

<h2>Chrome Extension — "SkillKit - Save as Skill"</h2>
<p>The Chrome extension operates entirely within your browser. It does not collect, transmit, or store any personal data externally.</p>
<p>The Chrome extension helps you save webpages as AI agent skill files.</p>
<ul>
<li><strong>No data collection:</strong> We do not collect browsing history, page content, personal information, or any other user data.</li>
<li><strong>No external requests:</strong> The extension makes zero network requests. All processing (HTML to markdown conversion, SKILL.md generation) happens locally in your browser.</li>
<li><strong>What data is sent:</strong> When you click "Save as Skill", the extension sends the URL of the current page to our server at <code>agenstskills.com/api/save-skill</code>. This is required to fetch and convert the webpage content into a skill file.</li>
<li><strong>What data is NOT sent:</strong> We do not send page content, browsing history, personal information, cookies, authentication tokens, or any data beyond the single URL you choose to save.</li>
<li><strong>No data stored on server:</strong> The URL is processed in real time to extract content, generate tags, and build the skill file. No URLs, page content, or user data are stored, logged, or retained on the server after the response is returned.</li>
<li><strong>Selection saves are local:</strong> When you save selected text via the right-click menu, the skill file is built entirely in the browser with no network requests.</li>
<li><strong>No analytics or tracking:</strong> We do not use any analytics, telemetry, or tracking services.</li>
<li><strong>Local storage only:</strong> The Chrome storage API is used solely to store user preferences (e.g., default settings) on your device. This data never leaves your browser.</li>
<li><strong>No user accounts:</strong> No login, API key, or account is required.</li>
<li><strong>Downloads:</strong> Generated skill files are saved to your local filesystem via the Chrome Downloads API. No files are uploaded anywhere.</li>
</ul>

<h2>Permissions Used</h2>
<ul>
<li><strong>activeTab:</strong> Read the URL and title of the tab you are viewing when you click the extension. Only accessed when you actively use the extension.</li>
<li><strong>contextMenus:</strong> Add "Save page as Skill" and "Save selection as Skill" to the right-click menu.</li>
<li><strong>downloads:</strong> Save the generated SKILL.md file to your Downloads folder.</li>
</ul>

<h2>CLI Tool</h2>
<p>The SkillKit CLI runs entirely on your local machine. It does not phone home, collect telemetry, or transmit any data to external servers unless you explicitly use network features (e.g., <code>skillkit install</code> fetches public GitHub repositories).</p>

Expand All @@ -49,10 +58,10 @@ <h2>Website</h2>
<p>The SkillKit website (<a href="https://agenstskills.com">agenstskills.com</a>) is a static site hosted on Vercel. We do not use cookies, analytics trackers, or collect personal information. Vercel may collect standard web server logs (IP address, user agent) as described in their <a href="https://vercel.com/legal/privacy-policy">privacy policy</a>.</p>

<h2>Third-Party Services</h2>
<p>SkillKit does not integrate with any third-party data processors, advertising networks, or analytics services.</p>
<p>The Chrome extension communicates with <code>agenstskills.com</code> (our own server, hosted on Vercel) to process webpage URLs. No third-party data processors, advertising networks, or analytics services are used.</p>

<h2>Data Retention</h2>
<p>We do not retain any user data because we do not collect any user data.</p>
<p>We do not retain any user data. URLs sent to the API are processed in memory and discarded immediately after the response.</p>

<h2>Changes to This Policy</h2>
<p>We may update this privacy policy from time to time. Changes will be posted on this page with an updated revision date.</p>
Expand Down
5 changes: 1 addition & 4 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"turndown": "^7.2.2"
},
"dependencies": {},
"devDependencies": {
"@types/chrome": "^0.0.280",
"@types/turndown": "^5.0.6",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
}
Expand Down
Loading