# Koa Security Audit Guide

> Koa.js Framework Security Audit Module
> Applicable to: Koa 2.x, Koa-Router, Koa-Body

## Core Attack Surface

As Express's streamlined successor, Koa uses middleware cascading architecture and async/await syntax. Main security risks: middleware ordering errors, request body parsing vulnerabilities, improper error handling, prototype pollution, path traversal, NoSQL injection, etc.

---

## Middleware Ordering and Security Configuration

```javascript
// Dangerous configuration - Middleware ordering error
const app = new Koa();

app.use(router.routes());        // ❌ Routes before security middleware
app.use(helmet());                // ❌ Too late

// Correct ordering
const app = new Koa();

// 1. Error handling (outermost layer)
app.use(async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        ctx.status = err.status || 500;
        ctx.body = { error: 'Internal Server Error' };  // ✓ Does not leak details
        ctx.app.emit('error', err, ctx);
    }
});

// 2. Security headers
app.use(helmet());  // ✓

// 3. CORS
app.use(cors({
    origin: 'https://app.example.com',  // ✓ Specific domain
    credentials: true
}));

// 4. Request body parsing
app.use(bodyParser({
    jsonLimit: '1mb',  // ✓ Limit size
    formLimit: '1mb'
}));

// 5. Authentication
app.use(auth());

// 6. Routes (last)
app.use(router.routes());
app.use(router.allowedMethods());
```

---

## Request Body Parsing and Prototype Pollution

```javascript
// Dangerous configuration - koa-body / koa-bodyparser
app.use(bodyParser());  // ❌ Default configuration may be insecure

// Audit checklist
- jsonLimit / formLimit configuration
- Whether __proto__ is filtered
- Whether Content-Type is validated

// Prototype pollution example
app.use(bodyParser());

app.post('/api/update', async (ctx) => {
    const data = ctx.request.body;
    Object.assign(config, data);  // ❌ High: Prototype pollution
    ctx.body = { success: true };
});

// Attack payload
POST /api/update
Content-Type: application/json

{
  "__proto__": {
    "isAdmin": true,
    "role": "admin"
  }
}

// Security fix
app.use(bodyParser({
    jsonLimit: '1mb',
    formLimit: '1mb',
    textLimit: '1mb',
    enableTypes: ['json', 'form'],  // ✓ Explicit types
    extendTypes: {
        json: ['application/json']
    }
}));

app.post('/api/update', async (ctx) => {
    const data = ctx.request.body;

    // 1. Filter dangerous keys
    const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
    for (const key of dangerousKeys) {
        if (key in data) {
            ctx.throw(400, 'Invalid key');
        }
    }

    // 2. Use whitelist
    const allowedKeys = ['name', 'email', 'bio'];
    const safeData = {};
    for (const key of allowedKeys) {
        if (data.hasOwnProperty(key)) {
            safeData[key] = data[key];
        }
    }

    Object.assign(config, safeData);  // ✓
    ctx.body = { success: true };
});
```

---

## NoSQL Injection Detection (MongoDB)

```javascript
// Dangerous operation
app.get('/user', async (ctx) => {
    const filter = ctx.query;
    const user = await User.findOne(filter);  // ❌ High: NoSQL injection
    ctx.body = user;
});

// Attack payload
GET /user?username[$ne]=null&password[$ne]=null  // Bypass authentication
GET /user?$where=this.password.length<20         // $where injection

// Audit regex
findOne\(ctx\.(query|request\.body)\)|find\(ctx\.(query|request\.body)\)
\$where|\$regex

// Security fix
app.get('/user', async (ctx) => {
    const { username } = ctx.query;

    // 1. Type validation
    if (typeof username !== 'string') {
        ctx.throw(400, 'Invalid username type');
    }

    // 2. Prohibit operators
    if (username.startsWith('$')) {
        ctx.throw(400, 'Invalid username');
    }

    // 3. Explicit fields
    const user = await User.findOne({ username: username });  // ✓

    ctx.body = user;
});

// Advanced protection - Recursive sanitization
function sanitizeQuery(obj) {
    if (typeof obj !== 'object' || obj === null) {
        return obj;
    }

    for (const key in obj) {
        // Delete operator keys
        if (key.startsWith('$')) {
            delete obj[key];
            continue;
        }

        // Recursive sanitization
        if (typeof obj[key] === 'object') {
            obj[key] = sanitizeQuery(obj[key]);
        }
    }

    return obj;
}

app.use(async (ctx, next) => {
    ctx.query = sanitizeQuery(ctx.query);  // ✓ Global sanitization
    if (ctx.request.body) {
        ctx.request.body = sanitizeQuery(ctx.request.body);
    }
    await next();
});
```

---

## SSRF Detection

```javascript
// Dangerous operation
const axios = require('axios');

app.get('/fetch', async (ctx) => {
    const url = ctx.query.url;
    const response = await axios.get(url);  // ❌ High: SSRF
    ctx.body = response.data;
});

// Attack payload
GET /fetch?url=http://169.254.169.254/latest/meta-data/
GET /fetch?url=http://localhost:6379/
GET /fetch?url=file:///etc/passwd

// Audit regex
axios\.(get|post).*ctx\.query|got\(ctx\.query|fetch\(ctx\.query

// Security fix
const { URL } = require('url');
const dns = require('dns').promises;

app.get('/fetch', async (ctx) => {
    const urlString = ctx.query.url;

    try {
        const url = new URL(urlString);

        // 1. Protocol whitelist
        if (!['http:', 'https:'].includes(url.protocol)) {
            ctx.throw(403, 'Invalid protocol');
        }

        // 2. Host whitelist
        const allowedHosts = ['api.example.com', 'cdn.example.com'];
        if (!allowedHosts.includes(url.hostname)) {
            ctx.throw(403, 'Host not allowed');
        }

        // 3. DNS resolution to check for internal IPs
        const addresses = await dns.resolve4(url.hostname);
        for (const addr of addresses) {
            if (isPrivateIP(addr)) {
                ctx.throw(403, 'Internal IP not allowed');
            }
        }

        // 4. Timeout and size limits
        const response = await axios.get(url.href, {
            timeout: 5000,
            maxContentLength: 1024 * 1024  // 1MB
        });

        ctx.body = response.data;
    } catch (err) {
        ctx.throw(400, 'Invalid request');
    }
});

// IP check function
function isPrivateIP(ip) {
    const parts = ip.split('.').map(Number);
    return (
        parts[0] === 10 ||
        parts[0] === 127 ||
        (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
        (parts[0] === 192 && parts[1] === 168) ||
        parts[0] === 169 && parts[1] === 254
    );
}
```

---

## Path Traversal Detection

```javascript
// Dangerous operation
const fs = require('fs').promises;
const path = require('path');

app.get('/download', async (ctx) => {
    const filename = ctx.query.file;
    const filePath = path.join(__dirname, 'files', filename);
    ctx.body = await fs.readFile(filePath);  // ❌ High: Path traversal
});

// Attack payload
GET /download?file=../../etc/passwd
GET /download?file=..%2f..%2f..%2fetc%2fpasswd

// Audit regex
fs\.readFile.*ctx\.query|fs\.createReadStream.*ctx\.query
path\.join.*ctx\.(query|params)

// Security fix
app.get('/download', async (ctx) => {
    const filename = ctx.query.file;

    // 1. Basic validation
    if (!filename || filename.includes('..') || filename.includes('/')) {
        ctx.throw(400, 'Invalid filename');
    }

    // 2. Extension whitelist
    const ext = path.extname(filename).toLowerCase();
    const allowedExts = ['.pdf', '.jpg', '.png', '.txt'];
    if (!allowedExts.includes(ext)) {
        ctx.throw(403, 'File type not allowed');
    }

    // 3. Path normalization and validation
    const baseDir = path.join(__dirname, 'files');
    const filePath = path.normalize(path.join(baseDir, filename));

    // Ensure final path is within baseDir
    if (!filePath.startsWith(baseDir + path.sep)) {
        ctx.throw(403, 'Access denied');
    }

    // 4. Check file exists
    try {
        await fs.access(filePath, fs.constants.R_OK);
    } catch {
        ctx.throw(404, 'File not found');
    }

    // 5. Set security headers
    ctx.set('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
    ctx.set('X-Content-Type-Options', 'nosniff');

    ctx.body = await fs.readFile(filePath);  // ✓
});
```

---

## Command Injection Detection

```javascript
// Dangerous operation
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);

app.get('/ping', async (ctx) => {
    const host = ctx.query.host;
    const { stdout } = await execPromise(`ping -c 4 ${host}`);  // ❌ Critical
    ctx.body = stdout;
});

// Attack payload
GET /ping?host=google.com;whoami
GET /ping?host=`cat /etc/passwd`

// Audit regex
exec\(.*ctx\.|spawn\(.*ctx\.|execSync\(.*ctx\.

// Security fix
const { execFile } = require('child_process');
const execFilePromise = util.promisify(execFile);

app.get('/ping', async (ctx) => {
    const host = ctx.query.host;

    // 1. Validate format
    if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
        ctx.throw(400, 'Invalid hostname');
    }

    // 2. Use execFile (does not spawn shell)
    try {
        const { stdout } = await execFilePromise('ping', ['-c', '4', host], {
            timeout: 10000
        });  // ✓
        ctx.body = stdout;
    } catch (err) {
        ctx.throw(500, 'Ping failed');
    }
});

// Safer: Avoid command execution, use native library
const ping = require('ping');

app.get('/ping', async (ctx) => {
    const host = ctx.query.host;
    const result = await ping.promise.probe(host);  // ✓ Pure JavaScript implementation
    ctx.body = result;
});
```

---

## JWT Security Detection

```javascript
// Dangerous operation
const jwt = require('jsonwebtoken');

app.use(async (ctx, next) => {
    const token = ctx.headers.authorization;
    const decoded = jwt.decode(token);  // ❌ High: Signature not verified
    ctx.state.user = decoded;
    await next();
});

// Weak secret
const SECRET = 'secret';  // ❌ Critical

// none algorithm
jwt.sign(payload, null, { algorithm: 'none' });  // ❌ Critical

// Audit regex
jwt\.decode\((?!.*verify)|JWT_SECRET.*=.*['"][^'"]{1,16}|algorithm.*none

// Security fix
app.use(async (ctx, next) => {
    const authHeader = ctx.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        ctx.throw(401, 'Missing token');
    }

    const token = authHeader.substring(7);

    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET, {
            algorithms: ['HS256']  // ✓ Explicit algorithm
        });
        ctx.state.user = decoded;
    } catch (err) {
        ctx.throw(401, 'Invalid token');
    }

    await next();
});

// Secret management
// .env
JWT_SECRET=at_least_32_bytes_random_string_here

// Generate strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
```

---

## CORS Configuration Detection

```javascript
// Dangerous configuration
const cors = require('@koa/cors');

app.use(cors({
    origin: '*',              // ❌ High: Allows all domains
    credentials: true         // ❌ Conflicts with *
}));

// Audit regex
cors\(\{[^}]*origin:.*\*|credentials:\s*true

// Vulnerability example
app.use(cors({
    origin: ctx => ctx.headers.origin  // ❌ High: Reflecting Origin
}));

// Security fix
// Method 1: Static whitelist
app.use(cors({
    origin: 'https://app.example.com',  // ✓
    credentials: true,
    allowMethods: ['GET', 'POST'],
    allowHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400
}));

// Method 2: Dynamic validation
const allowedOrigins = [
    'https://app.example.com',
    'https://admin.example.com'
];

app.use(cors({
    origin: (ctx) => {
        const origin = ctx.headers.origin;
        if (allowedOrigins.includes(origin)) {
            return origin;  // ✓
        }
        return false;  // Reject
    },
    credentials: true
}));

// Method 3: Subdomain pattern
app.use(cors({
    origin: (ctx) => {
        const origin = ctx.headers.origin;
        if (/^https:\/\/[a-z0-9-]+\.example\.com$/.test(origin)) {
            return origin;  // ✓ Allow subdomains
        }
        return false;
    },
    credentials: true
}));
```

---

## Error Handling and Information Disclosure

```javascript
// Dangerous configuration
app.on('error', (err, ctx) => {
    console.error(err.stack);  // ❌ Only logs
});

// Default error handling
app.use(async (ctx) => {
    throw new Error('Database connection failed: user=admin, pass=secret123');
    // ❌ Medium: Error may leak to client
});

// Audit regex
throw new Error.*password|console\.error.*stack|ctx\.body.*err\.stack

// Security fix
// Global error handling middleware
app.use(async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        ctx.status = err.status || err.statusCode || 500;

        // Backend logs full error
        console.error({
            error: err.message,
            stack: err.stack,
            url: ctx.url,
            method: ctx.method,
            ip: ctx.ip,
            user: ctx.state.user?.id
        });

        // Hide details in production
        if (process.env.NODE_ENV === 'production') {
            ctx.body = {
                error: 'An error occurred',
                code: err.code || 'INTERNAL_ERROR'
            };  // ✓
        } else {
            ctx.body = {
                error: err.message,
                stack: err.stack  // Development only
            };
        }

        // Trigger application-level error event
        ctx.app.emit('error', err, ctx);
    }
});

// Log sanitization
const logger = require('pino')();

app.use(async (ctx, next) => {
    const sanitized = { ...ctx.request.body };
    delete sanitized.password;
    delete sanitized.token;
    logger.info({ body: sanitized }, 'Request received');  // ✓
    await next();
});
```

---

## CSRF Protection

```javascript
// Koa has no built-in CSRF protection, manual implementation required
const csrf = require('koa-csrf');

// Dangerous: CSRF not enabled
app.use(router.routes());  // ❌ State-changing operations have no CSRF protection

// Safe configuration
app.use(new csrf({
    invalidTokenMessage: 'Invalid CSRF token',
    invalidTokenStatusCode: 403,
    excludedMethods: ['GET', 'HEAD', 'OPTIONS'],  // ✓ Only exempt safe methods
    disableQuery: false
}));

// Include CSRF token in form
app.use(async (ctx) => {
    await ctx.render('form', {
        csrfToken: ctx.csrf  // ✓
    });
});

// HTML
<form method="POST" action="/transfer">
    <input type="hidden" name="_csrf" value="{{ csrfToken }}">
    <input type="text" name="amount">
    <button type="submit">Transfer</button>
</form>

// AJAX request
axios.defaults.headers.common['X-CSRF-Token'] = csrfToken;
```

---

## Open Redirect Detection

```javascript
// Dangerous operation
app.get('/redirect', async (ctx) => {
    const url = ctx.query.url;
    ctx.redirect(url);  // ❌ Medium: Open redirect
});

// Attack payload
GET /redirect?url=https://evil.com/phishing

// Audit regex
ctx\.redirect\(ctx\.(query|request\.body)

// Security fix
app.get('/redirect', async (ctx) => {
    const url = ctx.query.url;

    // Method 1: Whitelist
    const allowedUrls = ['/home', '/dashboard', '/profile'];
    if (!allowedUrls.includes(url)) {
        ctx.redirect('/');  // Default safe page
        return;
    }

    // Method 2: Verify relative path
    if (!url.startsWith('/') || url.startsWith('//')) {
        ctx.throw(400, 'Invalid redirect URL');
    }

    // Method 3: Verify domain
    try {
        const parsed = new URL(url, 'http://example.com');
        if (parsed.hostname !== 'example.com') {
            ctx.throw(403, 'External redirect not allowed');
        }
    } catch {
        ctx.throw(400, 'Invalid URL');
    }

    ctx.redirect(url);  // ✓
});
```

---

## Rate Limiting

```javascript
// Missing rate limiting
app.post('/login', async (ctx) => {
    // ❌ Unlimited login attempts
});

// Safe configuration
const ratelimit = require('koa-ratelimit');
const Redis = require('ioredis');

const db = new Redis();

app.use(ratelimit({
    driver: 'redis',
    db: db,
    duration: 60000,  // 1 minute
    errorMessage: 'Too many requests',
    id: (ctx) => ctx.ip,
    headers: {
        remaining: 'Rate-Limit-Remaining',
        reset: 'Rate-Limit-Reset',
        total: 'Rate-Limit-Total'
    },
    max: 100,  // 100 requests per minute
    disableHeader: false
}));

// Rate limit for specific routes
const loginLimiter = ratelimit({
    driver: 'redis',
    db: db,
    duration: 60000,
    max: 5,  // 5 login attempts per minute
    id: (ctx) => ctx.request.body.email || ctx.ip
});

router.post('/login', loginLimiter, async (ctx) => {
    // Login logic
});
```

---

## Sensitive Information and Debugging

```javascript
// Dangerous operation
app.use(async (ctx) => {
    console.log(ctx.request.body);  // ❌ May log passwords
    ctx.body = process.env;  // ❌ Critical: Environment variable leakage
});

// .env leakage
DB_PASSWORD=secret123  // ❌ If code is leaked

// Audit regex
console\.log.*password|ctx\.body.*process\.env|DB_PASSWORD=

// Security measures
// .gitignore
.env
.env.local
.env.*.local

// Environment variable validation
const requiredEnvVars = ['DB_PASSWORD', 'JWT_SECRET', 'SESSION_SECRET'];
for (const varName of requiredEnvVars) {
    if (!process.env[varName]) {
        throw new Error(`Missing required env var: ${varName}`);
    }
}

// Log sanitization
const sanitizedBody = { ...ctx.request.body };
delete sanitizedBody.password;
delete sanitizedBody.creditCard;
logger.info({ body: sanitizedBody });  // ✓
```

---

## Search Pattern Summary

```regex
# Prototype Pollution
Object\.assign.*ctx\.request\.body|_\.merge.*ctx\.request\.body

# NoSQL Injection
findOne\(ctx\.query\)|find\(ctx\.request\.body\)

# SSRF
axios\.(get|post).*ctx\.query|fetch\(ctx\.query

# Path Traversal
fs\.readFile.*ctx\.query|createReadStream.*ctx\.query

# Command Injection
exec\(.*ctx\.|spawn\(.*ctx\.

# JWT
jwt\.decode\((?!.*verify)|algorithm.*none

# CORS
cors.*origin:.*\*

# Open Redirect
ctx\.redirect\(ctx\.query

# Sensitive Information
console\.log.*password|ctx\.body.*process\.env
```

---

## Quick Audit Checklist

```markdown
[ ] Check package.json dependency vulnerabilities (npm audit)
[ ] Check middleware ordering (security middleware should come first)
[ ] Search for Object.assign (prototype pollution)
[ ] Check findOne/find parameter source (NoSQL injection)
[ ] Check file operation path concatenation (path traversal)
[ ] Check HTTP request functions (SSRF)
[ ] Check exec/spawn command execution
[ ] Check JWT verification logic
[ ] Check CORS configuration
[ ] Check if error handling leaks information
[ ] Check CSRF protection
[ ] Check redirect target validation
[ ] Check rate limiting on login/sensitive endpoints
[ ] Check if .env is in .gitignore
```

---

## Minimum PoC Examples
```bash
# Prototype Pollution
node -e "const a={}; Object.assign(a, JSON.parse('{\"__proto__\":{\"polluted\":true}}')); console.log(({}).polluted)"

# SSRF
curl "http://localhost:3000/fetch?url=http://169.254.169.254/latest/meta-data/"

# Path Traversal
curl "http://localhost:3000/download?file=../../etc/passwd"
```

---

## Reference Resources

- [Koa Official Documentation](https://koajs.com/)
- [Koa Security Best Practices](https://github.com/koajs/koa/wiki/Error-Handling)
- [Node.js Security Checklist](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
