security: harden refresh-skills endpoint and add skills docs security gate
This commit is contained in:
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -18,6 +18,8 @@ tools:
|
||||
- codex-cli
|
||||
---
|
||||
|
||||
<!-- security-allowlist: curl-pipe-bash -->
|
||||
|
||||
# CLAUDE CODE EXPERT - Potencia Maxima
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -7,6 +7,8 @@ author: zebbern
|
||||
date_added: "2026-02-27"
|
||||
---
|
||||
|
||||
<!-- security-allowlist: curl-pipe-bash -->
|
||||
|
||||
# Cloud Penetration Testing
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,6 +7,8 @@ author: zebbern
|
||||
date_added: "2026-02-27"
|
||||
---
|
||||
|
||||
<!-- security-allowlist: curl-pipe-bash -->
|
||||
|
||||
# Linux Privilege Escalation
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user