skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
434 lines (359 loc) • 15.9 kB
Markdown
## Contents
- OWASP Top 10: Vulnerable Code → Fixed Code
- A01: Broken Access Control
- A02: Cryptographic Failures
- A03: Injection
- A04: Insecure Design
- A05: Security Misconfiguration
- A06: Vulnerable and Outdated Components
- A07: Identification and Authentication Failures
- A08: Software and Data Integrity Failures
- A09: Security Logging and Monitoring Failures
- A10: Server-Side Request Forgery (SSRF)
## OWASP Top 10: Vulnerable Code → Fixed Code
> Category numbers below follow the 2021 edition. The current edition is
> **OWASP Top 10:2025**, which reorders and renames: A01 Broken Access Control
> (SSRF now folds in here rather than standing alone), A02 Security
> Misconfiguration, A03 Software Supply Chain Failures, A04 Cryptographic
> Failures, A05 Injection, A06 Insecure Design, A07 Authentication Failures,
> A08 Software or Data Integrity Failures, A09 Security Logging and Alerting
> Failures, A10 Mishandling of Exceptional Conditions. Cite the 2025 numbers
> when reporting. The fixes below all still apply.
> For HTTP/JSON APIs, also work the **OWASP API Security Top 10 (2023)** — it
> catches API-specific gaps the web list underweights. The high-impact ones:
> **API1 BOLA** (object-level authz / IDOR — verify the caller owns *this* object
> on every request, see A01 below), **API3 Broken Object Property Level Auth**
> (mass-assignment + over-fetching — allowlist returned/updatable fields),
> **API5 BFLA** (function-level authz — admin routes need an explicit role gate,
> see RBAC), and **API4 Unrestricted Resource Consumption** (rate/size/cost
> limits — see Rate Limiting). For LLM/agent surfaces, see *AI-App Hardening*.
### A01: Broken Access Control
```javascript
// ❌ VULNERABLE: Checking ownership client-side only
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.findInvoice(req.params.id);
res.json(invoice); // Any authenticated user can view any invoice
});
// ✅ FIXED: Server-side ownership check
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.findInvoice(req.params.id);
if (!invoice) return res.status(404).json({ error: 'Not found' });
if (invoice.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(invoice);
});
```
### A02: Cryptographic Failures
```javascript
// ❌ VULNERABLE: Weak hashing, secrets in code
const hash = crypto.createHash('md5').update(password).digest('hex');
const JWT_SECRET = 'supersecret123';
// ✅ FIXED: Argon2 + env-based secrets
import argon2 from 'argon2';
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
const isValid = await argon2.verify(hash, password);
const JWT_SECRET = process.env.JWT_SECRET; // 256+ bit, from vault
if (!JWT_SECRET || JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
```
### A03: Injection
```javascript
// ❌ VULNERABLE: SQL injection
app.get('/api/users', async (req, res) => {
const users = await db.query(`SELECT * FROM users WHERE name = '${req.query.name}'`);
res.json(users);
});
// ✅ FIXED: Parameterized queries
app.get('/api/users', async (req, res) => {
const users = await db.query('SELECT * FROM users WHERE name = $1', [req.query.name]);
res.json(users);
});
// ❌ VULNERABLE: NoSQL injection (MongoDB)
const user = await User.findOne({ email: req.body.email, password: req.body.password });
// ✅ FIXED: Validate types
const email = String(req.body.email);
const password = String(req.body.password);
const user = await User.findOne({ email });
if (!user || !await argon2.verify(user.passwordHash, password)) {
throw new Error('Invalid credentials');
}
```
### A04: Insecure Design
```javascript
// ❌ VULNERABLE: Password reset with predictable token
const resetToken = String(Math.random()).slice(2);
// ✅ FIXED: Cryptographically secure token, hashed storage
import crypto from 'crypto';
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
await db.storeResetToken({
userId: user.id,
tokenHash: resetTokenHash,
expiresAt: new Date(Date.now() + 3600000), // 1 hour
});
// Send resetToken to user via email (never store raw)
// On reset: hash the provided token and compare with stored hash
```
### A05: Security Misconfiguration
```javascript
// ❌ VULNERABLE: Stack traces in production, default headers
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
// ✅ FIXED: Helmet + production error handling
import helmet from 'helmet';
app.use(helmet());
app.disable('x-powered-by');
app.use((err, req, res, next) => {
req.log.error({ err }, 'Unhandled error');
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: err.message,
});
});
```
### A06: Vulnerable and Outdated Components
```bash
# Regular audit (npm 10/11: --production was REMOVED; use --omit=dev)
npm audit --omit=dev --audit-level=high
npx better-npm-audit audit --level moderate
# Check for known vulnerabilities
npx socket npm info # Socket.dev: detects supply chain attacks
# Lock file integrity
npm ci # Always use ci, not install, in CI
# Automated PRs for updates
# Use Dependabot or Renovate (Renovate is better for monorepos)
```
### A07: Identification and Authentication Failures
```javascript
// ❌ VULNERABLE: No brute force protection, weak session
app.post('/api/login', async (req, res) => {
const user = await db.findByEmail(req.body.email);
if (user && user.password === req.body.password) {
res.json({ token: jwt.sign({ id: user.id }, SECRET) });
}
res.status(401).json({ error: 'Invalid credentials' });
});
// ✅ FIXED: Rate limiting, constant-time comparison, proper JWT
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 10, // 10 attempts per IP (`max` was renamed `limit` in v7)
skipSuccessfulRequests: true,
standardHeaders: true,
});
app.post('/api/login', loginLimiter, async (req, res) => {
const user = await db.findByEmail(req.body.email);
// Always hash-compare even if user not found (timing attack prevention)
const dummyHash = '$argon2id$v=19$m=65536,t=3,p=4$...'; // Pre-computed dummy
const hash = user?.passwordHash || dummyHash;
const isValid = await argon2.verify(hash, req.body.password);
if (!user || !isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// RS256 signs with a PRIVATE key, not a shared secret.
// (Passing a symmetric secret string with algorithm:'RS256' throws or
// invites key confusion — see the JWT Pitfalls section below.)
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_PRIVATE_KEY, // PEM RSA/EdDSA private key from vault
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
keyid: process.env.JWT_KID, // lets verifiers pick the right key on rotation
}
);
res.json({ accessToken });
});
// If you genuinely want a symmetric secret, use HS256 with a high-entropy key:
// jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256', ... })
// where JWT_SECRET is >= 32 random bytes (openssl rand -base64 48).
// Never pair an HS* secret string with an RS*/ES*/Ed* `algorithm` value.
```
### A08: Software and Data Integrity Failures
```javascript
// ❌ VULNERABLE: Deserializing untrusted data
const data = JSON.parse(Buffer.from(req.body.payload, 'base64').toString());
await processData(data);
// ✅ FIXED: Validate with schema
import { z } from 'zod';
const PayloadSchema = z.object({
action: z.enum(['create', 'update', 'delete']),
resourceId: z.string().uuid(),
data: z.record(z.unknown()).optional(),
});
// Capture the RAW body — HMAC must run over the exact bytes that were signed.
// JSON.stringify(req.body) re-serializes and will NOT match the sender's digest
// (key order, whitespace, and unicode escaping all differ).
import express from 'express';
app.use('/api/webhook', express.raw({ type: '*/*' })); // req.body is now a Buffer
// Length-checked constant-time compare. timingSafeEqual THROWS if the two
// buffers differ in length, so guard it (and never branch on length alone).
function safeEqualHex(a: string, b: string): boolean {
const ab = Buffer.from(a, 'hex');
const bb = Buffer.from(b, 'hex');
if (ab.length !== bb.length || ab.length === 0) return false;
return crypto.timingSafeEqual(ab, bb);
}
app.post('/api/webhook', (req, res) => {
const raw: Buffer = req.body; // exact bytes
// Header format here: "t=<unix>,v1=<hex hmac>" (Stripe-style). Parse defensively.
const header = String(req.headers['x-webhook-signature'] ?? '');
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=') as [string, string])
);
const ts = Number(parts.t);
const sig = parts.v1;
if (!Number.isFinite(ts) || !sig) {
return res.status(400).json({ error: 'Malformed signature header' });
}
// Replay window: reject anything older/newer than 5 minutes.
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).json({ error: 'Timestamp outside tolerance' });
}
// Sign timestamp + "." + raw body, matching the sender's signing scheme.
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(`${ts}.`)
.update(raw)
.digest('hex');
if (!safeEqualHex(sig, expected)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Idempotency / replay: store the event id (or sig) and reject duplicates.
// await redis.set(`wh:${sig}`, '1', 'EX', 600, 'NX') === null → already seen.
const payload = PayloadSchema.parse(JSON.parse(raw.toString('utf8')));
void processData(payload);
res.status(200).json({ ok: true });
});
```
**Framework notes for raw bodies:**
| Framework | How to get the raw body |
|-----------|-------------------------|
| Express | `express.raw({ type: '*/*' })` scoped to the webhook route (mount BEFORE `express.json()` or it consumes the stream first) |
| Fastify | `fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body))` then verify, then `JSON.parse` |
| Next.js (App Router) | In the route handler use `const raw = await req.text();` — body parsing is not automatic, so `raw` is already the signed payload |
| Stripe SDK | Prefer `stripe.webhooks.constructEvent(raw, sigHeader, secret)` — it does the timestamp + HMAC + replay checks for you |
### A09: Security Logging and Monitoring Failures
```javascript
// ✅ Log security-relevant events
const securityLogger = logger.child({ category: 'security' });
// Failed login attempts
securityLogger.warn({ email, ip: req.ip, userAgent: req.headers['user-agent'] },
'Failed login attempt');
// Privilege escalation attempts
securityLogger.error({ userId: req.user.id, attempted: 'admin', ip: req.ip },
'Unauthorized privilege escalation attempt');
// Unusual patterns
securityLogger.warn({ userId: req.user.id, count: requestCount, window: '1m' },
'Unusual request rate from user');
```
### A10: Server-Side Request Forgery (SSRF)
```javascript
// ❌ VULNERABLE: Fetching arbitrary URLs
app.post('/api/fetch-url', async (req, res) => {
const response = await fetch(req.body.url);
res.json(await response.json());
});
// ✅ FIXED: validate scheme, resolve A *and* AAAA, block private/cloud-metadata
// ranges, then PIN the resolved IP for the outbound connection. Validating the
// hostname and then calling fetch(url) separately is a TOCTOU/DNS-rebinding hole:
// DNS can return a public IP at check time and 169.254.169.254 at fetch time.
import { URL } from 'url';
import ipaddr from 'ipaddr.js';
import dns from 'dns/promises';
import { Agent } from 'undici';
// Reserved/dangerous ranges. ipaddr.range() covers most; add cloud metadata
// and IPv4-mapped-IPv6 explicitly because attackers reach metadata via both.
const BLOCKED_RANGES = new Set([
'unspecified', 'broadcast', 'multicast', 'linkLocal', 'loopback',
'private', 'reserved', 'uniqueLocal', 'ipv4Mapped', 'rfc6145', 'rfc6052',
'carrierGradeNat', // 100.64.0.0/10
]);
function isPublicIp(addr: string): boolean {
let ip = ipaddr.parse(addr);
// Normalize ::ffff:a.b.c.d so an IPv4 range check applies.
if (ip.kind() === 'ipv6' && (ip as ipaddr.IPv6).isIPv4MappedAddress()) {
ip = (ip as ipaddr.IPv6).toIPv4Address();
}
if (BLOCKED_RANGES.has(ip.range())) return false;
// Cloud metadata endpoints (AWS/GCP/Azure 169.254.169.254, GCP fd00:ec2::254,
// Alibaba 100.100.100.200) — defense in depth on top of range checks.
const s = ip.toNormalizedString();
if (s === '169.254.169.254' || s === '100.100.100.200' || s === 'fd00:ec2::254') {
return false;
}
return true;
}
async function resolveSafe(hostname: string): Promise<string> {
// Resolve BOTH families; reject if ANY answer is non-public.
const results = await Promise.allSettled([
dns.resolve4(hostname),
dns.resolve6(hostname),
]);
const ips = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []));
if (ips.length === 0) throw new Error('No DNS records');
for (const ip of ips) if (!isPublicIp(ip)) throw new Error(`Blocked IP: ${ip}`);
return ips[0]; // pin this one for the connection
}
// Custom undici dispatcher that connects to the pre-validated IP. Node's global
// fetch is undici-based and silently IGNORES an `agent` option (http/https Agents
// are not undici dispatchers), so pinning MUST go through a dispatcher. The URL
// keeps the original hostname, so the Host header and TLS SNI stay correct; only
// resolution is overridden. This closes the rebinding gap.
// (If you use node-fetch instead of global fetch, pass an http/https Agent with a
// custom `lookup` via its `agent` option to get the same effect.)
function pinnedDispatcher(ip: string) {
const family = ipaddr.parse(ip).kind() === 'ipv6' ? 6 : 4;
return new Agent({
connect: {
// net/tls connect option: force resolution to the pre-validated IP
lookup: (_host, opts, cb) =>
(opts as any).all ? cb(null, [{ address: ip, family }]) : (cb as any)(null, ip, family),
},
});
}
app.post('/api/fetch-url', async (req, res) => {
let url: URL;
try {
url = new URL(String(req.body.url));
} catch {
return res.status(400).json({ error: 'Invalid URL' });
}
// Block non-HTTP schemes: file:, gopher:, ftp:, data:, dict:, etc.
if (!['http:', 'https:'].includes(url.protocol)) {
return res.status(400).json({ error: 'Only http/https allowed' });
}
if (url.username || url.password) {
return res.status(400).json({ error: 'Credentials in URL not allowed' });
}
let ip: string;
try {
ip = await resolveSafe(url.hostname);
} catch {
return res.status(400).json({ error: 'URL not allowed' });
}
const response = await fetch(url, {
dispatcher: pinnedDispatcher(ip),
redirect: 'error', // re-validate manually if you must follow redirects:
// for each 3xx Location, parse → resolveSafe() again → re-pin → refetch.
signal: AbortSignal.timeout(5000),
} as RequestInit & { dispatcher: Agent });
res.json(await response.json());
});
```
> Simpler, more robust in production: route all user-driven outbound traffic
> through a **dedicated egress proxy** (e.g. Smokescreen) on a network with no
> route to internal/metadata subnets, so the app never resolves untrusted hosts
> itself. Pair with `redirect: 'error'` and a request timeout regardless.
---