security: harden refresh-skills endpoint and add skills docs security gate

This commit is contained in:
sck_0
2026-03-15 09:51:16 +01:00
parent c0c118e223
commit 6f42d5b0a2
15 changed files with 344 additions and 14 deletions

View File

@@ -110,6 +110,9 @@ jobs:
ENABLE_NETWORK_TESTS: "1"
run: npm run test
- name: Run docs security checks
run: npm run security:docs
artifact-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
@@ -220,6 +223,9 @@ jobs:
ENABLE_NETWORK_TESTS: "1"
run: npm run test
- name: Run docs security checks
run: npm run security:docs
- name: Build catalog
run: npm run catalog

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import crypto from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -44,6 +45,57 @@ function isGitAvailable() {
return _gitAvailable;
}
function normalizeHost(hostValue = '') {
return String(hostValue).trim().toLowerCase().replace(/^\[|\]$/g, '');
}
function isLoopbackHost(hostname) {
const host = normalizeHost(hostname);
return host === 'localhost'
|| host === '::1'
|| host.startsWith('127.');
}
function getRequestHost(req) {
const hostHeader = req.headers?.host || '';
if (!hostHeader) {
return '';
}
try {
return new URL(`http://${hostHeader}`).hostname;
} catch {
return normalizeHost(hostHeader);
}
}
function isDevLoopbackRequest(req) {
return isLoopbackHost(getRequestHost(req));
}
function isTokenAuthorized(req) {
const expectedToken = (process.env.SKILLS_REFRESH_TOKEN || '').trim();
if (!expectedToken) {
return true;
}
const providedToken = req.headers?.['x-skills-refresh-token'];
if (typeof providedToken !== 'string' || !providedToken) {
return false;
}
const expected = Buffer.from(expectedToken);
const provided = Buffer.from(providedToken);
if (expected.length !== provided.length) {
return false;
}
return crypto.timingSafeEqual(expected, provided);
}
/** Run a git command in the project root. */
function git(cmd) {
return execSync(`git ${cmd}`, { cwd: ROOT_DIR, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -272,12 +324,30 @@ export default function refreshSkillsPlugin() {
return;
}
if (!req.headers?.host || !req.headers?.origin) {
res.statusCode = 400;
res.end(JSON.stringify({ success: false, error: 'Missing request host or origin headers' }));
return;
}
if (!isDevLoopbackRequest(req)) {
res.statusCode = 403;
res.end(JSON.stringify({ success: false, error: 'Only local loopback requests are allowed' }));
return;
}
if (!isAllowedDevOrigin(req)) {
res.statusCode = 403;
res.end(JSON.stringify({ success: false, error: 'Forbidden origin' }));
return;
}
if (!isTokenAuthorized(req)) {
res.statusCode = 401;
res.end(JSON.stringify({ success: false, error: 'Invalid or missing refresh token' }));
return;
}
try {
let result;

View File

@@ -62,6 +62,7 @@ async function loadRefreshHandler() {
describe('refresh-skills plugin security', () => {
beforeEach(() => {
execSync.mockClear();
delete process.env.SKILLS_REFRESH_TOKEN;
});
it('rejects GET requests for the sync endpoint', async () => {
@@ -95,4 +96,87 @@ describe('refresh-skills plugin security', () => {
expect(res.statusCode).toBe(403);
});
it('rejects non-loopback POST requests for the sync endpoint', async () => {
const handler = await loadRefreshHandler();
const req = {
method: 'POST',
headers: {
host: '192.168.1.1:5173',
origin: 'http://192.168.1.1:5173',
},
};
const res = createResponse();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toMatch('loopback');
});
it('rejects token-less requests when refresh token is configured', async () => {
process.env.SKILLS_REFRESH_TOKEN = 'super-secret-token';
const handler = await loadRefreshHandler();
const req = {
method: 'POST',
headers: {
host: 'localhost:5173',
origin: 'http://localhost:5173',
},
};
const res = createResponse();
await handler(req, res);
expect(res.statusCode).toBe(401);
});
it('accepts local requests by default without a refresh token', async () => {
const handler = await loadRefreshHandler();
const req = {
method: 'POST',
headers: {
host: 'localhost:5173',
origin: 'http://localhost:5173',
},
};
const res = createResponse();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
});
it('accepts IPv6 loopback requests by default without a refresh token', async () => {
const handler = await loadRefreshHandler();
const req = {
method: 'POST',
headers: {
host: '[::1]:5173',
origin: 'http://[::1]:5173',
},
};
const res = createResponse();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).success).toBe(true);
});
it('rejects POST requests with missing host/origin headers', async () => {
const handler = await loadRefreshHandler();
const req = {
method: 'POST',
headers: {
host: 'localhost:5173',
},
};
const res = createResponse();
await handler(req, res);
expect(res.statusCode).toBe(400);
});
});

View File

@@ -14,6 +14,7 @@
"sync:all": "npm run sync:metadata && npm run chain",
"catalog": "node tools/scripts/build-catalog.js",
"build": "npm run chain && npm run catalog",
"security:docs": "node tools/scripts/tests/docs_security_content.test.js",
"pr:preflight": "node tools/scripts/pr_preflight.js",
"release:preflight": "node tools/scripts/release_workflow.js preflight",
"release:prepare": "node tools/scripts/release_workflow.js prepare",

View File

@@ -3,6 +3,8 @@ name: apify-actor-development
description: "Develop, debug, and deploy Apify Actors - serverless cloud programs for web scraping, automation, and data processing. Use when creating new Actors, modifying existing ones, or troubleshooting Acto..."
---
<!-- security-allowlist: curl-pipe-bash, irm-pipe-iex -->
# Apify Actor Development
**Important:** Before you begin, fill in the `generatedBy` property in the meta section of `.actor/actor.json`. Replace it with the tool and model you're currently using, such as "Claude Code with Claude Sonnet 4.5". This helps Apify monitor and improve AGENTS.md for specific AI tools and models.

View File

@@ -10,6 +10,8 @@ tags: [security, audit, skills, bundles, cross-platform]
tools: [claude, gemini, gpt, llama, mistral, etc]
---
<!-- security-allowlist: curl-pipe-bash -->
# Audit Skills (Premium Universal Security)
## Overview

View File

@@ -6,6 +6,8 @@ source: community
date_added: "2026-02-27"
---
<!-- security-allowlist: curl-pipe-bash, irm-pipe-iex -->
# ⚡ Bun Development
> Fast, modern JavaScript/TypeScript development with the Bun runtime, inspired by [oven-sh/bun](https://github.com/oven-sh/bun).

View File

@@ -18,6 +18,8 @@ tools:
- codex-cli
---
<!-- security-allowlist: curl-pipe-bash -->
# CLAUDE CODE EXPERT - Potencia Maxima
## Overview

View File

@@ -7,6 +7,8 @@ author: zebbern
date_added: "2026-02-27"
---
<!-- security-allowlist: curl-pipe-bash -->
# Cloud Penetration Testing
## Purpose

View File

@@ -7,6 +7,8 @@ description: |
hooks, hook system, auto-trigger, skill...
---
<!-- security-allowlist: curl-pipe-bash -->
# Makepad Skills Evolution
This skill enables makepad-skills to self-improve continuously during development.

View File

@@ -6,6 +6,8 @@ source: community
date_added: "2026-02-27"
---
<!-- security-allowlist: curl-pipe-bash -->
# Linkerd Patterns
Production patterns for Linkerd service mesh - the lightweight, security-first service mesh for Kubernetes.

View File

@@ -7,6 +7,8 @@ author: zebbern
date_added: "2026-02-27"
---
<!-- security-allowlist: curl-pipe-bash -->
# Linux Privilege Escalation
## Purpose

View File

@@ -4,6 +4,8 @@ description: Secure environment variable management with Varlock. Use when handl
--- 1.0.0
---
<!-- security-allowlist: curl-pipe-bash -->
# Varlock Security Skill
Secure-by-default environment variable management for Claude Code sessions.
@@ -431,4 +433,4 @@ Add these to your package.json:
---
*Last updated: December 22, 2025*
*Secure-by-default environment management for Claude Code*
*Secure-by-default environment management for Claude Code*

View File

@@ -1,21 +1,171 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, "../..", "..");
const repoRoot = path.resolve(__dirname, '../..', '..');
const apifySkill = fs.readFileSync(
path.join(repoRoot, "skills", "apify-actorization", "SKILL.md"),
"utf8",
path.join(repoRoot, 'skills', 'apify-actorization', 'SKILL.md'),
'utf8',
);
const audioExample = fs.readFileSync(
path.join(repoRoot, "skills", "audio-transcriber", "examples", "basic-transcription.sh"),
"utf8",
path.join(repoRoot, 'skills', 'audio-transcriber', 'examples', 'basic-transcription.sh'),
'utf8',
);
assert.strictEqual(/\|\s*(bash|sh)\b/.test(apifySkill), false, "SKILL.md must not recommend pipe-to-shell installs");
assert.strictEqual(/\|\s*iex\b/i.test(apifySkill), false, "SKILL.md must not recommend PowerShell pipe-to-iex installs");
assert.strictEqual(/apify login -t\b/.test(apifySkill), false, "SKILL.md must not put tokens on the command line");
function findSkillFiles(skillsRoot) {
const files = [];
const queue = [skillsRoot];
assert.match(audioExample, /python3 << 'EOF'/, "audio example should use a quoted heredoc for Python");
assert.match(audioExample, /AUDIO_FILE_ENV/, "audio example should pass shell variables through the environment");
while (queue.length > 0) {
const current = queue.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'SKILL.md') {
files.push(fullPath);
}
}
}
return files;
}
function parseAllowlist(content) {
const allowAllRe = /<!--\s*security-allowlist:\s*all\s*-->/i;
const explicitRe = /<!--\s*security-allowlist:\s*([^>]+?)\s*-->/gi;
const allow = new Set();
if (allowAllRe.test(content)) {
allow.add('all');
return allow;
}
let match;
while ((match = explicitRe.exec(content)) !== null) {
const raw = match[1] || '';
raw
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.forEach((value) => {
allow.add(value.toLowerCase().replace(/[^a-z0-9_-]/g, ''));
});
}
return allow;
}
function isAllowed(allowlist, ruleId) {
if (allowlist.has('all')) {
return true;
}
const normalized = ruleId.toLowerCase().replace(/[^a-z0-9_-]/g, '');
return allowlist.has(normalized)
|| allowlist.has(normalized.replace(/[-_]/g, ''))
|| allowlist.has(`allow${normalized}`)
|| allowlist.has(`risk${normalized}`);
}
const rules = [
{
id: 'curl-pipe-bash',
message: 'curl ... | bash|sh',
regex: /\bcurl\b[^\n]*\|\s*(?:bash|sh)\b/i,
},
{
id: 'wget-pipe-sh',
message: 'wget ... | sh',
regex: /\bwget\b[^\n]*\|\s*sh\b/i,
},
{
id: 'irm-pipe-iex',
message: 'irm ... | iex',
regex: /\birm\b[^\n]*\|\s*iex\b/i,
},
{
id: 'commandline-token',
message: 'command-line token arguments',
regex: /\s(?:--token|--api[_-]?(?:key|token)|--access[_-]?token|--auth(?:entication)?[_-]?token|--secret|--api[_-]?secret|--refresh[_-]?token)\s+['\"]?([A-Za-z0-9._=\-:+/]{16,})['\"]?/i,
},
];
function collectSkillFiles(basePaths) {
const files = new Set();
for (const basePath of basePaths) {
if (!fs.existsSync(basePath)) {
continue;
}
for (const filePath of findSkillFiles(basePath)) {
files.add(filePath);
}
}
return [...files];
}
const rootsToScan = [path.join(repoRoot, 'skills')];
if ((process.env.DOCS_SECURITY_INCLUDE_PUBLIC || '').trim() === '1') {
rootsToScan.push(path.join(repoRoot, 'apps/web-app/public/skills'));
}
const skillFiles = collectSkillFiles(rootsToScan);
assert.ok(skillFiles.length > 0, 'Expected SKILL.md files in configured scan roots');
const violations = [];
const seen = new Set();
function addViolation(relativePath, lineNumber, rule) {
const key = `${relativePath}:${lineNumber}:${rule.id}`;
if (seen.has(key)) {
return;
}
seen.add(key);
violations.push(`${relativePath}:${lineNumber}: ${rule.message}`);
}
for (const filePath of skillFiles) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
const allowlist = parseAllowlist(content);
const relativePath = path.relative(repoRoot, filePath);
for (const rule of rules) {
for (const [index, line] of lines.entries()) {
if (!rule.regex.test(line)) {
continue;
}
if (isAllowed(allowlist, rule.id)) {
continue;
}
addViolation(relativePath, index + 1, rule);
rule.regex.lastIndex = 0;
}
}
}
assert.strictEqual(violationCount(violations), 0, violations.join('\n'));
assert.match(audioExample, /python3 << 'EOF'/, 'audio example should use a quoted heredoc for Python');
assert.match(audioExample, /AUDIO_FILE_ENV/, 'audio example should pass shell variables through the environment');
assert.strictEqual(/\|\s*(bash|sh)\b/.test(apifySkill), false, 'SKILL.md must not recommend pipe-to-shell installs');
assert.strictEqual(/\|\s*iex\b/i.test(apifySkill), false, 'SKILL.md must not recommend PowerShell pipe-to-iex installs');
assert.strictEqual(/apify login -t\b/.test(apifySkill), false, 'SKILL.md must not put tokens on the command line');
function violationCount(list) {
return list.length;
}

View File

@@ -11,6 +11,7 @@ const LOCAL_TEST_COMMANDS = [
[path.join(TOOL_TESTS, "jetski_gemini_loader.test.js")],
[path.join(TOOL_TESTS, "validate_skills_headings.test.js")],
[path.join(TOOL_TESTS, "workflow_contracts.test.js")],
[path.join(TOOL_TESTS, "docs_security_content.test.js")],
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_validate_skills_headings.py")],
];
const NETWORK_TEST_COMMANDS = [