secure-coding-rules 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -60
- package/package.json +5 -5
- package/src/__tests__/adapters.test.js +201 -0
- package/src/__tests__/loader.test.js +68 -0
- package/src/adapters/agents.js +2 -2
- package/src/adapters/claude.js +42 -3
- package/src/adapters/copilot.js +46 -2
- package/src/i18n.js +271 -0
- package/src/index.js +222 -56
- package/src/loader.js +33 -20
- package/src/prompts.js +79 -77
- package/src/templates/frameworks/express-security.md +130 -0
- package/src/templates/frameworks/nextjs-security.md +149 -0
- package/src/templates/frameworks/react-security.md +120 -0
- package/src/templates/typescript/typescript-security.md +113 -0
package/src/prompts.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { createInterface } from 'node:readline';
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { t } from './i18n.js';
|
|
9
10
|
|
|
10
11
|
// ─── Readline helpers ────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -24,7 +25,7 @@ function ask(question) {
|
|
|
24
25
|
|
|
25
26
|
function printOptions(options) {
|
|
26
27
|
options.forEach((opt, i) => {
|
|
27
|
-
const marker = opt.detected ?
|
|
28
|
+
const marker = opt.detected ? ` (${t('detected')})` : '';
|
|
28
29
|
console.log(` ${i + 1}) ${opt.label}${marker}`);
|
|
29
30
|
});
|
|
30
31
|
}
|
|
@@ -32,20 +33,18 @@ function printOptions(options) {
|
|
|
32
33
|
async function selectOne(question, options) {
|
|
33
34
|
console.log(`\n${question}`);
|
|
34
35
|
printOptions(options);
|
|
35
|
-
const answer = await ask(`\
|
|
36
|
+
const answer = await ask(`\n${t('selectPrompt')} (1-${options.length}): `);
|
|
36
37
|
const idx = parseInt(answer, 10) - 1;
|
|
37
38
|
if (idx >= 0 && idx < options.length) return options[idx].value;
|
|
38
|
-
console.log('
|
|
39
|
+
console.log(t('invalidSelection'));
|
|
39
40
|
return options[0].value;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
async function selectMultiple(question, options) {
|
|
43
44
|
console.log(`\n${question}`);
|
|
44
45
|
printOptions(options);
|
|
45
|
-
console.log(` 0)
|
|
46
|
-
const answer = await ask(
|
|
47
|
-
`\nSelect (comma-separated, e.g. 1,3,5 or 0 for all): `
|
|
48
|
-
);
|
|
46
|
+
console.log(` 0) ${t('selectAll')}`);
|
|
47
|
+
const answer = await ask(`\n${t('selectPrompt')} (${t('selectAllComma')}): `);
|
|
49
48
|
|
|
50
49
|
if (answer === '0' || answer.toLowerCase() === 'all') {
|
|
51
50
|
return options.map((o) => o.value);
|
|
@@ -57,7 +56,7 @@ async function selectMultiple(question, options) {
|
|
|
57
56
|
.filter((i) => i >= 0 && i < options.length);
|
|
58
57
|
|
|
59
58
|
if (indices.length === 0) {
|
|
60
|
-
console.log('
|
|
59
|
+
console.log(t('noValidSelection'));
|
|
61
60
|
return options.map((o) => o.value);
|
|
62
61
|
}
|
|
63
62
|
|
|
@@ -101,16 +100,14 @@ export const SECURITY_CATEGORIES = [
|
|
|
101
100
|
{ label: 'Frontend: CSRF Protection', value: 'csrf-protection' },
|
|
102
101
|
{ label: 'Frontend: CSP', value: 'csp' },
|
|
103
102
|
{ label: 'Frontend: Secure State Management', value: 'secure-state' },
|
|
103
|
+
{ label: 'TypeScript Security', value: 'typescript-security' },
|
|
104
|
+
{ label: 'Framework: React / Next.js', value: 'react-security' },
|
|
105
|
+
{ label: 'Framework: Express / Node.js', value: 'express-security' },
|
|
106
|
+
{ label: 'Framework: Next.js (App Router)', value: 'nextjs-security' },
|
|
104
107
|
];
|
|
105
108
|
|
|
106
109
|
// ─── Project state detection ─────────────────────────────────────
|
|
107
110
|
|
|
108
|
-
/**
|
|
109
|
-
* Detect the current project environment:
|
|
110
|
-
* - Which AI tool configs already exist
|
|
111
|
-
* - What framework is being used (via package.json)
|
|
112
|
-
* - Whether this is a new or existing project
|
|
113
|
-
*/
|
|
114
111
|
export function detectProjectState(cwd) {
|
|
115
112
|
const state = {
|
|
116
113
|
hasPackageJson: existsSync(join(cwd, 'package.json')),
|
|
@@ -119,7 +116,6 @@ export function detectProjectState(cwd) {
|
|
|
119
116
|
existingRules: {},
|
|
120
117
|
};
|
|
121
118
|
|
|
122
|
-
// Detect existing AI tool configs
|
|
123
119
|
const toolPaths = {
|
|
124
120
|
claude: 'CLAUDE.md',
|
|
125
121
|
cursor: '.cursor/rules',
|
|
@@ -136,7 +132,6 @@ export function detectProjectState(cwd) {
|
|
|
136
132
|
}
|
|
137
133
|
}
|
|
138
134
|
|
|
139
|
-
// Detect framework from package.json dependencies
|
|
140
135
|
if (state.hasPackageJson) {
|
|
141
136
|
try {
|
|
142
137
|
const pkg = JSON.parse(
|
|
@@ -159,30 +154,27 @@ export function detectProjectState(cwd) {
|
|
|
159
154
|
return state;
|
|
160
155
|
}
|
|
161
156
|
|
|
162
|
-
/**
|
|
163
|
-
* Print a summary of the detected project state
|
|
164
|
-
*/
|
|
165
157
|
export function printProjectStatus(state) {
|
|
166
|
-
console.log(
|
|
158
|
+
console.log(`\n📋 ${t('projectStatus')}`);
|
|
167
159
|
|
|
168
160
|
if (state.detectedTools.length > 0) {
|
|
169
161
|
const toolNames = state.detectedTools
|
|
170
|
-
.map((
|
|
162
|
+
.map((tool) => AI_TOOLS.find((a) => a.value === tool)?.label || tool)
|
|
171
163
|
.join(', ');
|
|
172
|
-
console.log(`
|
|
164
|
+
console.log(` ${t('toolsDetected')} ${toolNames}`);
|
|
173
165
|
} else {
|
|
174
|
-
console.log(
|
|
166
|
+
console.log(` ${t('noToolsFound')}`);
|
|
175
167
|
}
|
|
176
168
|
|
|
177
169
|
if (state.detectedFramework) {
|
|
178
170
|
const fwName =
|
|
179
171
|
FRAMEWORKS.find((f) => f.value === state.detectedFramework)?.label ||
|
|
180
172
|
state.detectedFramework;
|
|
181
|
-
console.log(`
|
|
173
|
+
console.log(` ${t('frameworkDetected')} ${fwName}`);
|
|
182
174
|
}
|
|
183
175
|
|
|
184
176
|
if (!state.hasPackageJson) {
|
|
185
|
-
console.log(
|
|
177
|
+
console.log(` ${t('noPackageJson')}`);
|
|
186
178
|
}
|
|
187
179
|
}
|
|
188
180
|
|
|
@@ -196,28 +188,26 @@ export async function promptUser(args) {
|
|
|
196
188
|
const cwd = process.cwd();
|
|
197
189
|
const state = detectProjectState(cwd);
|
|
198
190
|
|
|
199
|
-
// --check flag
|
|
191
|
+
// --check flag
|
|
200
192
|
if (args.includes('--check')) {
|
|
201
193
|
printProjectStatus(state);
|
|
202
|
-
return null;
|
|
194
|
+
return null;
|
|
203
195
|
}
|
|
204
196
|
|
|
205
|
-
// Non-interactive mode
|
|
197
|
+
// Non-interactive mode
|
|
206
198
|
if (args.includes('--yes') || args.includes('-y') || !isInteractive()) {
|
|
207
199
|
if (!isInteractive() && !args.includes('--yes') && !args.includes('-y')) {
|
|
208
|
-
console.log('
|
|
200
|
+
console.log(t('nonInteractive'));
|
|
209
201
|
}
|
|
210
202
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
: state.detectedTools.includes('claude')
|
|
216
|
-
? 'claude'
|
|
217
|
-
: 'claude';
|
|
203
|
+
const tools =
|
|
204
|
+
state.detectedTools.length > 0
|
|
205
|
+
? state.detectedTools
|
|
206
|
+
: ['claude'];
|
|
218
207
|
|
|
219
208
|
return {
|
|
220
|
-
|
|
209
|
+
tools,
|
|
210
|
+
outputMode: 'inline',
|
|
221
211
|
framework: state.detectedFramework || 'vanilla',
|
|
222
212
|
categories: SECURITY_CATEGORIES.map((c) => c.value),
|
|
223
213
|
includeFrontend: (state.detectedFramework || 'vanilla') !== 'node',
|
|
@@ -225,29 +215,34 @@ export async function promptUser(args) {
|
|
|
225
215
|
}
|
|
226
216
|
|
|
227
217
|
// Interactive mode
|
|
228
|
-
console.log(
|
|
218
|
+
console.log(`\n🔒 ${t('title')}\n`);
|
|
229
219
|
console.log('─'.repeat(55));
|
|
230
220
|
printProjectStatus(state);
|
|
231
221
|
|
|
232
|
-
// AI tool selection
|
|
233
|
-
const toolOptions = AI_TOOLS.map((
|
|
234
|
-
...
|
|
235
|
-
detected: state.detectedTools.includes(
|
|
222
|
+
// AI tool selection (multiple)
|
|
223
|
+
const toolOptions = AI_TOOLS.map((tool) => ({
|
|
224
|
+
...tool,
|
|
225
|
+
detected: state.detectedTools.includes(tool.value),
|
|
236
226
|
}));
|
|
237
227
|
|
|
238
|
-
|
|
239
|
-
if (state.detectedTools.length === 1) {
|
|
240
|
-
const detected = state.detectedTools[0];
|
|
241
|
-
const idx = toolOptions.findIndex((t) => t.value === detected);
|
|
242
|
-
if (idx > 0) {
|
|
243
|
-
const [item] = toolOptions.splice(idx, 1);
|
|
244
|
-
toolOptions.unshift(item);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
228
|
+
const tools = await selectMultiple(t('selectTools'), toolOptions);
|
|
247
229
|
|
|
248
|
-
|
|
230
|
+
// Output mode selection
|
|
231
|
+
// Only relevant for tools that support both modes (claude, copilot, agents)
|
|
232
|
+
const supportsDirectory = tools.some((t) =>
|
|
233
|
+
['claude', 'copilot'].includes(t)
|
|
234
|
+
);
|
|
249
235
|
|
|
250
|
-
|
|
236
|
+
let outputMode = 'inline';
|
|
237
|
+
if (supportsDirectory) {
|
|
238
|
+
const modeOptions = [
|
|
239
|
+
{ label: `${t('outputInline')}`, value: 'inline' },
|
|
240
|
+
{ label: `${t('outputDirectory')}`, value: 'directory' },
|
|
241
|
+
];
|
|
242
|
+
outputMode = await selectOne(t('selectOutputMode'), modeOptions);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Framework selection
|
|
251
246
|
const frameworkOptions = FRAMEWORKS.map((f) => ({
|
|
252
247
|
...f,
|
|
253
248
|
detected: f.value === state.detectedFramework,
|
|
@@ -262,46 +257,53 @@ export async function promptUser(args) {
|
|
|
262
257
|
}
|
|
263
258
|
}
|
|
264
259
|
|
|
265
|
-
const framework = await selectOne(
|
|
266
|
-
'What is your primary framework?',
|
|
267
|
-
frameworkOptions
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
// Update or fresh install message
|
|
271
|
-
if (state.existingRules[tool]) {
|
|
272
|
-
console.log(`\n ℹ Existing rules found - will update security section only.`);
|
|
273
|
-
}
|
|
260
|
+
const framework = await selectOne(t('selectFramework'), frameworkOptions);
|
|
274
261
|
|
|
275
|
-
const allCategories = await confirm(
|
|
276
|
-
'Include all OWASP 2025 security categories?'
|
|
277
|
-
);
|
|
262
|
+
const allCategories = await confirm(t('includeAll'));
|
|
278
263
|
|
|
279
264
|
let categories;
|
|
280
265
|
if (allCategories) {
|
|
281
266
|
categories = SECURITY_CATEGORIES.map((c) => c.value);
|
|
282
267
|
} else {
|
|
283
|
-
categories = await selectMultiple(
|
|
284
|
-
'Select security categories:',
|
|
285
|
-
SECURITY_CATEGORIES
|
|
286
|
-
);
|
|
268
|
+
categories = await selectMultiple(t('selectCategories'), SECURITY_CATEGORIES);
|
|
287
269
|
}
|
|
288
270
|
|
|
289
271
|
const includeFrontend =
|
|
290
272
|
framework !== 'node'
|
|
291
|
-
? await confirm('
|
|
273
|
+
? await confirm(t('includeFrontend'))
|
|
292
274
|
: false;
|
|
293
275
|
|
|
294
276
|
if (includeFrontend) {
|
|
295
|
-
const frontendCats = [
|
|
296
|
-
'xss-prevention',
|
|
297
|
-
'csrf-protection',
|
|
298
|
-
'csp',
|
|
299
|
-
'secure-state',
|
|
300
|
-
];
|
|
277
|
+
const frontendCats = ['xss-prevention', 'csrf-protection', 'csp', 'secure-state'];
|
|
301
278
|
frontendCats.forEach((c) => {
|
|
302
279
|
if (!categories.includes(c)) categories.push(c);
|
|
303
280
|
});
|
|
304
281
|
}
|
|
305
282
|
|
|
306
|
-
|
|
283
|
+
// Auto-include framework-specific and TypeScript rules
|
|
284
|
+
autoIncludeExtras(categories, framework);
|
|
285
|
+
|
|
286
|
+
return { tools, outputMode, framework, categories, includeFrontend };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Auto-include TypeScript and framework-specific rules based on selection
|
|
291
|
+
*/
|
|
292
|
+
function autoIncludeExtras(categories, framework) {
|
|
293
|
+
// Always include TypeScript rules (most JS projects use TS now)
|
|
294
|
+
if (!categories.includes('typescript-security')) {
|
|
295
|
+
categories.push('typescript-security');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const frameworkMap = {
|
|
299
|
+
react: ['react-security', 'nextjs-security'],
|
|
300
|
+
vue: [],
|
|
301
|
+
node: ['express-security'],
|
|
302
|
+
vanilla: [],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const extras = frameworkMap[framework] || [];
|
|
306
|
+
extras.forEach((c) => {
|
|
307
|
+
if (!categories.includes(c)) categories.push(c);
|
|
308
|
+
});
|
|
307
309
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Express/Node.js Security Rules
|
|
2
|
+
|
|
3
|
+
> Security rules for Express.js and Node.js server applications, covering middleware hardening, input validation, and secure error handling.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
|
|
7
|
+
### 1. Use Helmet Middleware for HTTP Security Headers
|
|
8
|
+
- **DO**: Install and apply `helmet()` as early as possible in the middleware chain. Fine-tune individual headers (CSP, HSTS, X-Frame-Options) based on your application's needs.
|
|
9
|
+
- **DON'T**: Serve responses without security headers. Never rely on the browser's default behavior to protect against clickjacking, MIME sniffing, or missing HSTS.
|
|
10
|
+
- **WHY**: Helmet sets critical HTTP response headers that mitigate entire classes of attacks (XSS, clickjacking, MIME confusion) with minimal configuration effort.
|
|
11
|
+
|
|
12
|
+
### 2. Configure CORS Explicitly — Never Use Wildcard Origins
|
|
13
|
+
- **DO**: Set specific allowed origins in your CORS configuration. Validate the `Origin` header against a strict allowlist. Restrict `methods` and `allowedHeaders` to only what the client needs.
|
|
14
|
+
- **DON'T**: Use `origin: "*"` or reflect the request `Origin` header back without validation. Never combine `credentials: true` with a wildcard origin.
|
|
15
|
+
- **WHY**: Wildcard CORS allows any website to make cross-origin requests to your API. Combined with credentials, this lets attackers perform authenticated actions on behalf of users via malicious sites.
|
|
16
|
+
|
|
17
|
+
### 3. Apply Rate Limiting to All Endpoints
|
|
18
|
+
- **DO**: Use `express-rate-limit` (or similar) globally, with stricter limits on authentication, password reset, and payment endpoints. Include `keyGenerator` based on authenticated user ID where possible.
|
|
19
|
+
- **DON'T**: Leave endpoints unprotected from high-volume requests. Never rely solely on IP-based rate limiting behind shared proxies without `trust proxy` configuration.
|
|
20
|
+
- **WHY**: Rate limiting prevents brute-force attacks, credential stuffing, and denial-of-service. Without it, attackers can enumerate users, brute-force passwords, or exhaust server resources.
|
|
21
|
+
|
|
22
|
+
### 4. Set Body Parser Size Limits
|
|
23
|
+
- **DO**: Configure explicit size limits on `express.json()` and `express.urlencoded()` (e.g., `limit: "100kb"`). Set even stricter limits for file uploads using multer or busboy.
|
|
24
|
+
- **DON'T**: Accept unlimited request body sizes. Never use default parser settings in production without reviewing limits.
|
|
25
|
+
- **WHY**: Oversized payloads can exhaust server memory, cause out-of-memory crashes, or be used in denial-of-service attacks. A 10MB JSON body can block the event loop during parsing.
|
|
26
|
+
|
|
27
|
+
### 5. Configure `trust proxy` Correctly
|
|
28
|
+
- **DO**: Set `trust proxy` to the exact number of proxies in front of your app (e.g., `app.set("trust proxy", 1)` for one reverse proxy). Validate your setup by logging `req.ip` in staging.
|
|
29
|
+
- **DON'T**: Set `trust proxy` to `true` (trusts all proxies) in production. Never leave it unconfigured when behind a load balancer.
|
|
30
|
+
- **WHY**: Incorrect `trust proxy` settings make `req.ip` unreliable. Attackers can spoof `X-Forwarded-For` headers to bypass IP-based rate limiting and access controls.
|
|
31
|
+
|
|
32
|
+
### 6. Never Expose Stack Traces or Internal Details in Error Responses
|
|
33
|
+
- **DO**: Use a centralized error handler that returns generic error messages to clients and logs full error details (stack trace, query, context) server-side only.
|
|
34
|
+
- **DON'T**: Send `err.stack`, `err.message`, or database error details in API responses. Never use Express's default error handler in production.
|
|
35
|
+
- **WHY**: Stack traces reveal file paths, library versions, database schemas, and internal logic. Attackers use this information for targeted exploitation.
|
|
36
|
+
|
|
37
|
+
### 7. Use Parameterized Queries for All Database Operations
|
|
38
|
+
- **DO**: Use parameterized queries, prepared statements, or ORM query builders (Knex, Prisma, Sequelize) for all database operations. Validate and sanitize inputs before they reach the query layer.
|
|
39
|
+
- **DON'T**: Concatenate or template-literal user input into SQL or NoSQL query strings. Never build MongoDB queries with unsanitized `req.body` objects.
|
|
40
|
+
- **WHY**: SQL and NoSQL injection remain top attack vectors. Parameterized queries separate code from data, making injection structurally impossible regardless of input content.
|
|
41
|
+
|
|
42
|
+
## Code Examples
|
|
43
|
+
|
|
44
|
+
### Bad Practice
|
|
45
|
+
```javascript
|
|
46
|
+
const express = require("express");
|
|
47
|
+
const app = express();
|
|
48
|
+
|
|
49
|
+
// No helmet, no security headers
|
|
50
|
+
app.use(express.json()); // No size limit
|
|
51
|
+
|
|
52
|
+
// Wildcard CORS — any site can call this API
|
|
53
|
+
const cors = require("cors");
|
|
54
|
+
app.use(cors({ origin: "*", credentials: true }));
|
|
55
|
+
|
|
56
|
+
// Stack trace leaked in error response
|
|
57
|
+
app.use((err, req, res, next) => {
|
|
58
|
+
res.status(500).json({ error: err.message, stack: err.stack });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// SQL injection via string concatenation
|
|
62
|
+
app.get("/api/users", async (req, res) => {
|
|
63
|
+
const query = `SELECT * FROM users WHERE name = '${req.query.name}'`;
|
|
64
|
+
const users = await db.raw(query);
|
|
65
|
+
res.json(users);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// No rate limiting on login
|
|
69
|
+
app.post("/api/login", async (req, res) => {
|
|
70
|
+
const user = await authenticate(req.body.email, req.body.password);
|
|
71
|
+
res.json({ token: generateToken(user) });
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Good Practice
|
|
76
|
+
```javascript
|
|
77
|
+
const express = require("express");
|
|
78
|
+
const helmet = require("helmet");
|
|
79
|
+
const cors = require("cors");
|
|
80
|
+
const rateLimit = require("express-rate-limit");
|
|
81
|
+
|
|
82
|
+
const app = express();
|
|
83
|
+
|
|
84
|
+
// Security headers
|
|
85
|
+
app.use(helmet());
|
|
86
|
+
|
|
87
|
+
// Strict CORS
|
|
88
|
+
app.use(cors({
|
|
89
|
+
origin: ["https://app.example.com"],
|
|
90
|
+
methods: ["GET", "POST", "PUT", "DELETE"],
|
|
91
|
+
credentials: true,
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// Body size limit
|
|
95
|
+
app.use(express.json({ limit: "100kb" }));
|
|
96
|
+
|
|
97
|
+
// Trust proxy (behind one reverse proxy)
|
|
98
|
+
app.set("trust proxy", 1);
|
|
99
|
+
|
|
100
|
+
// Global rate limit
|
|
101
|
+
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
|
|
102
|
+
|
|
103
|
+
// Strict rate limit on auth
|
|
104
|
+
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
|
|
105
|
+
app.post("/api/login", authLimiter, async (req, res) => {
|
|
106
|
+
const user = await authenticate(req.body.email, req.body.password);
|
|
107
|
+
res.json({ token: generateToken(user) });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Parameterized query (Knex example)
|
|
111
|
+
app.get("/api/users", async (req, res) => {
|
|
112
|
+
const users = await db("users").where("name", req.query.name);
|
|
113
|
+
res.json(users);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Centralized error handler — no stack trace leakage
|
|
117
|
+
app.use((err, req, res, next) => {
|
|
118
|
+
console.error(err); // Full details server-side only
|
|
119
|
+
res.status(err.status || 500).json({ error: "Internal server error" });
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Quick Checklist
|
|
124
|
+
- [ ] `helmet()` is applied before all route handlers
|
|
125
|
+
- [ ] CORS `origin` is set to specific allowed domains, not `"*"`
|
|
126
|
+
- [ ] `express-rate-limit` is applied globally and with stricter limits on auth endpoints
|
|
127
|
+
- [ ] `express.json()` and `express.urlencoded()` have explicit `limit` set
|
|
128
|
+
- [ ] `trust proxy` is set to the correct number of proxies, not `true`
|
|
129
|
+
- [ ] Error handler returns generic messages; stack traces are logged server-side only
|
|
130
|
+
- [ ] All database queries use parameterized queries or ORM query builders
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Next.js Security Rules
|
|
2
|
+
|
|
3
|
+
> Security rules for Next.js applications using the App Router, covering Server Actions, middleware authentication, environment variable safety, and secure data fetching patterns.
|
|
4
|
+
|
|
5
|
+
## Rules
|
|
6
|
+
|
|
7
|
+
### 1. Validate All Inputs in Server Actions with Schema Validation
|
|
8
|
+
- **DO**: Use Zod or a similar library to validate every input in Server Actions. Treat Server Actions as public API endpoints — validate, sanitize, and authorize before processing.
|
|
9
|
+
- **DON'T**: Trust form data or arguments passed to Server Actions without validation. Never assume the client-side form structure limits what can be submitted.
|
|
10
|
+
- **WHY**: Server Actions are HTTP POST endpoints under the hood. Attackers can call them directly with arbitrary payloads, bypassing all client-side validation and form structure.
|
|
11
|
+
|
|
12
|
+
### 2. Enforce Authentication and Authorization in `middleware.ts`
|
|
13
|
+
- **DO**: Use `middleware.ts` to protect routes by verifying session tokens or JWTs before the request reaches any page or API route. Define public routes explicitly via matcher config.
|
|
14
|
+
- **DON'T**: Rely on individual page components or layouts to check authentication. Never skip authorization checks assuming middleware already handled them.
|
|
15
|
+
- **WHY**: Middleware runs at the edge before any rendering occurs, providing a centralized and reliable enforcement point. Component-level checks are too late — data may already be fetched and serialized.
|
|
16
|
+
|
|
17
|
+
### 3. Understand `NEXT_PUBLIC_` Environment Variable Exposure
|
|
18
|
+
- **DO**: Only prefix environment variables with `NEXT_PUBLIC_` when the value is intentionally public. Keep API keys, database URLs, and secrets without the prefix so they remain server-only.
|
|
19
|
+
- **DON'T**: Add `NEXT_PUBLIC_` to secrets or internal service URLs. Never store sensitive values in `NEXT_PUBLIC_` variables "for convenience" in client components.
|
|
20
|
+
- **WHY**: `NEXT_PUBLIC_` variables are inlined into the client-side JavaScript bundle at build time. They are visible to anyone who views your page source — this is by design and cannot be reversed.
|
|
21
|
+
|
|
22
|
+
### 4. Validate HTTP Methods in API Routes
|
|
23
|
+
- **DO**: Explicitly check and handle each HTTP method in API route handlers. Use Next.js App Router's named exports (`GET`, `POST`, `PUT`, `DELETE`) to restrict allowed methods.
|
|
24
|
+
- **DON'T**: Handle all methods in a single catch-all handler without method validation. Never allow `GET` requests to perform state-changing operations.
|
|
25
|
+
- **WHY**: API routes that accept any method can be exploited via CSRF (GET requests with image tags), method confusion, or unintended side effects from HEAD/OPTIONS requests.
|
|
26
|
+
|
|
27
|
+
### 5. Never Include Sensitive Data in ISR/SSG Pages
|
|
28
|
+
- **DO**: Fetch only public data during static generation (`generateStaticParams`, page-level data fetching). Load user-specific or sensitive data client-side or in server components that are not cached.
|
|
29
|
+
- **DON'T**: Include tokens, internal IDs, admin data, or PII in statically generated pages. Never pass sensitive data through `searchParams` that end up in cached pages.
|
|
30
|
+
- **WHY**: ISR/SSG pages are cached as static HTML files and served from CDN edges. Sensitive data in these pages is shared across all users and persists until the next revalidation.
|
|
31
|
+
|
|
32
|
+
### 6. Use `next/headers` and `next/cookies` Securely
|
|
33
|
+
- **DO**: Set cookies with `httpOnly`, `secure`, `sameSite: "strict"`, and appropriate `path` / `maxAge`. Read cookies and headers only in server components or route handlers.
|
|
34
|
+
- **DON'T**: Store sensitive tokens in cookies without `httpOnly` and `secure` flags. Never trust cookie values without server-side validation.
|
|
35
|
+
- **WHY**: Cookies without `httpOnly` are accessible to JavaScript and vulnerable to XSS theft. Without `secure`, cookies transmit over HTTP. Without `sameSite`, cookies are vulnerable to CSRF.
|
|
36
|
+
|
|
37
|
+
### 7. Protect Server-Only Code with `server-only` Package
|
|
38
|
+
- **DO**: Add `import "server-only"` at the top of files containing database queries, secrets access, or internal business logic. This causes a build error if the file is imported by a client component.
|
|
39
|
+
- **DON'T**: Rely on convention alone to separate server and client code. Never assume that a file without `"use client"` is safe from client bundling.
|
|
40
|
+
- **WHY**: Next.js's bundler may include server-side files in the client bundle if they are transitively imported by a client component. The `server-only` package provides a compile-time guarantee that prevents accidental exposure.
|
|
41
|
+
|
|
42
|
+
## Code Examples
|
|
43
|
+
|
|
44
|
+
### Bad Practice
|
|
45
|
+
```typescript
|
|
46
|
+
// Server Action without validation
|
|
47
|
+
"use server";
|
|
48
|
+
async function updateProfile(formData: FormData) {
|
|
49
|
+
const name = formData.get("name") as string;
|
|
50
|
+
await db.user.update({ where: { id: userId }, data: { name } }); // No validation
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Secret exposed via NEXT_PUBLIC_
|
|
54
|
+
// .env
|
|
55
|
+
// NEXT_PUBLIC_DB_URL=postgresql://admin:password@db.internal:5432/prod
|
|
56
|
+
// NEXT_PUBLIC_API_SECRET=sk-secret-key-12345
|
|
57
|
+
|
|
58
|
+
// API route accepting any method
|
|
59
|
+
export async function handler(req: NextRequest) {
|
|
60
|
+
// GET, POST, DELETE all hit the same code path
|
|
61
|
+
const data = await req.json();
|
|
62
|
+
await db.user.delete({ where: { id: data.id } });
|
|
63
|
+
return Response.json({ success: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Sensitive data in ISR page
|
|
67
|
+
export const revalidate = 3600;
|
|
68
|
+
export default async function AdminPage() {
|
|
69
|
+
const users = await db.user.findMany({
|
|
70
|
+
select: { id: true, email: true, ssn: true }, // SSN in static page!
|
|
71
|
+
});
|
|
72
|
+
return <UserTable users={users} />;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Good Practice
|
|
77
|
+
```typescript
|
|
78
|
+
// Server Action with Zod validation and authorization
|
|
79
|
+
"use server";
|
|
80
|
+
import { z } from "zod";
|
|
81
|
+
import { auth } from "@/lib/auth";
|
|
82
|
+
|
|
83
|
+
const UpdateProfileSchema = z.object({
|
|
84
|
+
name: z.string().min(1).max(100).trim(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
async function updateProfile(formData: FormData) {
|
|
88
|
+
const session = await auth();
|
|
89
|
+
if (!session?.user) throw new Error("Unauthorized");
|
|
90
|
+
const result = UpdateProfileSchema.safeParse({ name: formData.get("name") });
|
|
91
|
+
if (!result.success) throw new Error("Invalid input");
|
|
92
|
+
await db.user.update({
|
|
93
|
+
where: { id: session.user.id },
|
|
94
|
+
data: { name: result.data.name },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// middleware.ts — centralized auth
|
|
99
|
+
import { NextResponse } from "next/server";
|
|
100
|
+
import type { NextRequest } from "next/server";
|
|
101
|
+
|
|
102
|
+
const publicPaths = ["/login", "/register", "/api/health"];
|
|
103
|
+
|
|
104
|
+
export function middleware(request: NextRequest) {
|
|
105
|
+
if (publicPaths.some((p) => request.nextUrl.pathname.startsWith(p))) {
|
|
106
|
+
return NextResponse.next();
|
|
107
|
+
}
|
|
108
|
+
const token = request.cookies.get("session-token")?.value;
|
|
109
|
+
if (!token) return NextResponse.redirect(new URL("/login", request.url));
|
|
110
|
+
return NextResponse.next();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const config = { matcher: ["/((?!_next/static|favicon.ico).*)"] };
|
|
114
|
+
|
|
115
|
+
// Environment variables: no NEXT_PUBLIC_ prefix for secrets
|
|
116
|
+
// DATABASE_URL=postgresql://... (server-only, no prefix)
|
|
117
|
+
// NEXT_PUBLIC_APP_URL=https://app.example.com (safe to expose)
|
|
118
|
+
|
|
119
|
+
// server-only guard
|
|
120
|
+
import "server-only";
|
|
121
|
+
import { db } from "@/lib/db";
|
|
122
|
+
|
|
123
|
+
export async function getSecretConfig() {
|
|
124
|
+
return db.config.findFirst(); // Build error if imported by client component
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Secure cookie setting
|
|
128
|
+
import { cookies } from "next/headers";
|
|
129
|
+
|
|
130
|
+
async function setSessionCookie(token: string) {
|
|
131
|
+
const cookieStore = await cookies();
|
|
132
|
+
cookieStore.set("session-token", token, {
|
|
133
|
+
httpOnly: true,
|
|
134
|
+
secure: true,
|
|
135
|
+
sameSite: "strict",
|
|
136
|
+
maxAge: 60 * 60 * 24, // 1 day
|
|
137
|
+
path: "/",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Quick Checklist
|
|
143
|
+
- [ ] All Server Actions validate inputs with Zod or equivalent schema validation
|
|
144
|
+
- [ ] `middleware.ts` enforces authentication on all protected routes
|
|
145
|
+
- [ ] No secrets or internal URLs use the `NEXT_PUBLIC_` prefix
|
|
146
|
+
- [ ] API routes use named method exports (`GET`, `POST`) instead of catch-all handlers
|
|
147
|
+
- [ ] ISR/SSG pages contain only public, non-sensitive data
|
|
148
|
+
- [ ] Cookies are set with `httpOnly`, `secure`, and `sameSite` flags
|
|
149
|
+
- [ ] Server-only files use `import "server-only"` as a compile-time guard
|