secure-vibe-runtime 0.0.0-stage → 7.3.0
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 +54 -2
- package/dist/convex/index.d.ts +4 -0
- package/dist/convex/index.js +4 -0
- package/dist/convex/redact.d.ts +21 -0
- package/dist/convex/redact.js +34 -0
- package/dist/convex/securityCounts.d.ts +38 -0
- package/dist/convex/securityCounts.js +69 -0
- package/dist/convex/securityLogger.d.ts +67 -0
- package/dist/convex/securityLogger.js +214 -0
- package/dist/convex/securityTables.d.ts +214 -0
- package/dist/convex/securityTables.js +67 -0
- package/dist/csrf.d.ts +18 -0
- package/dist/csrf.js +66 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/secrets.d.ts +28 -0
- package/dist/secrets.js +111 -0
- package/dist/withCsrf.d.ts +22 -0
- package/dist/withCsrf.js +104 -0
- package/dist/withRateLimit.d.ts +23 -0
- package/dist/withRateLimit.js +168 -0
- package/package.json +72 -4
package/dist/withCsrf.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { validateCsrfToken } from "./csrf";
|
|
3
|
+
import { ConvexHttpClient } from 'convex/browser';
|
|
4
|
+
import { makeFunctionReference } from 'convex/server';
|
|
5
|
+
/**
|
|
6
|
+
* The app's public security-log mutation. Named rather than imported from the
|
|
7
|
+
* app's generated api so this file can live in a package; the app registers
|
|
8
|
+
* `logSecurityViolation` in convex/security.ts with exactly these args.
|
|
9
|
+
*/
|
|
10
|
+
const logSecurityViolation = makeFunctionReference('security:logSecurityViolation');
|
|
11
|
+
// Lazy-initialized Convex client for security logging
|
|
12
|
+
let convexClient = null;
|
|
13
|
+
function getConvexClient() {
|
|
14
|
+
if (!process.env.NEXT_PUBLIC_CONVEX_URL) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!convexClient) {
|
|
18
|
+
convexClient = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL);
|
|
19
|
+
}
|
|
20
|
+
return convexClient;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Log a CSRF violation to the security dashboard (non-blocking)
|
|
24
|
+
*/
|
|
25
|
+
function logCsrfViolation(request, reason) {
|
|
26
|
+
const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
27
|
+
request.headers.get('x-real-ip') ||
|
|
28
|
+
'unknown';
|
|
29
|
+
const endpoint = request.nextUrl.pathname;
|
|
30
|
+
const origin = request.headers.get('origin') || undefined;
|
|
31
|
+
// The mutation requires the shared SECURITY_LOG_KEY so that only this server,
|
|
32
|
+
// not anyone holding the public deployment URL, can write to the security log.
|
|
33
|
+
const client = getConvexClient();
|
|
34
|
+
const key = process.env.SECURITY_LOG_KEY;
|
|
35
|
+
if (client && !key) {
|
|
36
|
+
console.error('SECURITY_LOG_KEY is not set; CSRF violation not logged to Convex');
|
|
37
|
+
}
|
|
38
|
+
if (client && key) {
|
|
39
|
+
client.mutation(logSecurityViolation, {
|
|
40
|
+
key,
|
|
41
|
+
eventType: 'csrf_validation_failed',
|
|
42
|
+
severity: 'critical',
|
|
43
|
+
metadata: {
|
|
44
|
+
endpoint,
|
|
45
|
+
ipAddress: ip,
|
|
46
|
+
origin,
|
|
47
|
+
errorMessage: reason,
|
|
48
|
+
},
|
|
49
|
+
}).catch((logErr) => {
|
|
50
|
+
console.error('Failed to log CSRF violation:', logErr);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Higher-order function that wraps API route handlers with CSRF protection
|
|
56
|
+
* Validates the CSRF token from the request header against the stored cookie
|
|
57
|
+
* Tokens are single-use and cleared after validation
|
|
58
|
+
*
|
|
59
|
+
* @param handler - The API route handler to wrap
|
|
60
|
+
* @returns A wrapped handler with CSRF protection
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* import { withCsrf } from '@/lib/withCsrf';
|
|
65
|
+
*
|
|
66
|
+
* async function loginHandler(request: NextRequest) {
|
|
67
|
+
* // Your login logic here
|
|
68
|
+
* return NextResponse.json({ success: true });
|
|
69
|
+
* }
|
|
70
|
+
*
|
|
71
|
+
* export const POST = withCsrf(loginHandler);
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function withCsrf(handler) {
|
|
75
|
+
return async (request) => {
|
|
76
|
+
try {
|
|
77
|
+
const csrfToken = request.headers.get("x-csrf-token");
|
|
78
|
+
const storedToken = request.cookies.get("XSRF-TOKEN")?.value;
|
|
79
|
+
if (!csrfToken || !storedToken) {
|
|
80
|
+
const reason = !csrfToken ? "Missing CSRF token in header" : "Missing CSRF cookie";
|
|
81
|
+
console.error("CSRF check failed: Missing token", {
|
|
82
|
+
sent: csrfToken ? "[present]" : "[missing]",
|
|
83
|
+
stored: storedToken ? "[present]" : "[missing]",
|
|
84
|
+
});
|
|
85
|
+
logCsrfViolation(request, reason);
|
|
86
|
+
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
|
87
|
+
}
|
|
88
|
+
if (!validateCsrfToken(csrfToken, storedToken)) {
|
|
89
|
+
console.error("CSRF check failed: Token mismatch");
|
|
90
|
+
logCsrfViolation(request, "CSRF token mismatch");
|
|
91
|
+
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
|
92
|
+
}
|
|
93
|
+
// Call the original handler
|
|
94
|
+
const response = await handler(request);
|
|
95
|
+
// Clear the CSRF token after use (single-use tokens)
|
|
96
|
+
response.cookies.set("XSRF-TOKEN", "", { maxAge: 0, path: "/" });
|
|
97
|
+
return response;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.error("CSRF middleware error:", err.message);
|
|
101
|
+
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
/**
|
|
3
|
+
* Higher-order function that wraps API route handlers with rate limiting
|
|
4
|
+
* Tracks requests by IP address and returns 429 when limit is exceeded
|
|
5
|
+
*
|
|
6
|
+
* @param handler - The API route handler to wrap
|
|
7
|
+
* @returns A wrapped handler with rate limiting protection
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { withRateLimit } from '@/lib/withRateLimit';
|
|
12
|
+
* import { withCsrf } from '@/lib/withCsrf';
|
|
13
|
+
*
|
|
14
|
+
* async function sensitiveHandler(request: NextRequest) {
|
|
15
|
+
* // Your API logic here
|
|
16
|
+
* return NextResponse.json({ success: true });
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* // Apply both rate limiting and CSRF protection
|
|
20
|
+
* export const POST = withRateLimit(withCsrf(sensitiveHandler));
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function withRateLimit(handler: (request: NextRequest) => Promise<NextResponse>): (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
|
3
|
+
import { ConvexHttpClient } from 'convex/browser';
|
|
4
|
+
import { makeFunctionReference } from 'convex/server';
|
|
5
|
+
/**
|
|
6
|
+
* The app's public security-log mutation. Named rather than imported from the
|
|
7
|
+
* app's generated api so this file can live in a package; the app registers
|
|
8
|
+
* `logSecurityViolation` in convex/security.ts with exactly these args.
|
|
9
|
+
*/
|
|
10
|
+
const logSecurityViolation = makeFunctionReference('security:logSecurityViolation');
|
|
11
|
+
/**
|
|
12
|
+
* Rate limiter instance - 5 requests per minute per IP address
|
|
13
|
+
*
|
|
14
|
+
* SCOPE LIMITATION — READ BEFORE RELYING ON THIS FOR ABUSE PREVENTION.
|
|
15
|
+
*
|
|
16
|
+
* RateLimiterMemory keeps its counters in the memory of ONE process. Vercel runs
|
|
17
|
+
* this app across many function instances and recycles them freely, so:
|
|
18
|
+
*
|
|
19
|
+
* - a caller spread across N warm instances gets up to N x 5 requests/minute
|
|
20
|
+
* - counters reset whenever an instance is recycled
|
|
21
|
+
*
|
|
22
|
+
* That makes this a speed bump against naive scripted abuse, NOT a guarantee.
|
|
23
|
+
* It is honest defence in depth; it is not a quota.
|
|
24
|
+
*
|
|
25
|
+
* For a real limit you need shared durable state (Redis / Upstash) keyed the
|
|
26
|
+
* same way, or enforcement at a layer that already sees every request — Vercel's
|
|
27
|
+
* WAF rate limiting is the least-effort option and requires no code.
|
|
28
|
+
*
|
|
29
|
+
* For Convex mutations specifically this wrapper does nothing at all: Convex RPC
|
|
30
|
+
* goes browser -> Convex and never passes through a Next.js route handler. Limit
|
|
31
|
+
* those inside the mutation.
|
|
32
|
+
*/
|
|
33
|
+
const rateLimiter = new RateLimiterMemory({
|
|
34
|
+
points: 5, // 5 requests
|
|
35
|
+
duration: 60, // per 60 seconds (1 minute)
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Resolves the caller's IP from platform-trusted headers.
|
|
39
|
+
*
|
|
40
|
+
* Why not the leftmost X-Forwarded-For entry: XFF is an append-only list, and
|
|
41
|
+
* the LEFTMOST entry is whatever the client sent. A caller can simply set
|
|
42
|
+
* `X-Forwarded-For: <random>` on each request and receive a fresh bucket every
|
|
43
|
+
* time, defeating the limit entirely. The previous implementation did exactly
|
|
44
|
+
* this, so the limiter could be bypassed by anyone who knew to rotate a header.
|
|
45
|
+
*
|
|
46
|
+
* Order of preference:
|
|
47
|
+
* 1. `x-vercel-forwarded-for` — set by Vercel's edge, overwritten on every
|
|
48
|
+
* request, so a client cannot forge it.
|
|
49
|
+
* 2. The RIGHTMOST `x-forwarded-for` entry — appended by the closest trusted
|
|
50
|
+
* proxy, rather than the leftmost value the client supplied.
|
|
51
|
+
* 3. `x-real-ip` — set by the proxy, not the client, on common setups.
|
|
52
|
+
*
|
|
53
|
+
* If none are present we return null and the caller fails CLOSED (see below),
|
|
54
|
+
* because a single shared "unknown" bucket would let one abuser exhaust the
|
|
55
|
+
* limit for every anonymous visitor at once.
|
|
56
|
+
*/
|
|
57
|
+
function clientIp(request) {
|
|
58
|
+
const vercel = request.headers.get('x-vercel-forwarded-for')?.trim();
|
|
59
|
+
if (vercel)
|
|
60
|
+
return vercel;
|
|
61
|
+
const xff = request.headers.get('x-forwarded-for');
|
|
62
|
+
if (xff) {
|
|
63
|
+
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
|
|
64
|
+
if (parts.length > 0)
|
|
65
|
+
return parts[parts.length - 1];
|
|
66
|
+
}
|
|
67
|
+
const real = request.headers.get('x-real-ip')?.trim();
|
|
68
|
+
if (real)
|
|
69
|
+
return real;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// Lazy-initialized Convex client for security logging
|
|
73
|
+
let convexClient = null;
|
|
74
|
+
function getConvexClient() {
|
|
75
|
+
if (!process.env.NEXT_PUBLIC_CONVEX_URL) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!convexClient) {
|
|
79
|
+
convexClient = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL);
|
|
80
|
+
}
|
|
81
|
+
return convexClient;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Higher-order function that wraps API route handlers with rate limiting
|
|
85
|
+
* Tracks requests by IP address and returns 429 when limit is exceeded
|
|
86
|
+
*
|
|
87
|
+
* @param handler - The API route handler to wrap
|
|
88
|
+
* @returns A wrapped handler with rate limiting protection
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* import { withRateLimit } from '@/lib/withRateLimit';
|
|
93
|
+
* import { withCsrf } from '@/lib/withCsrf';
|
|
94
|
+
*
|
|
95
|
+
* async function sensitiveHandler(request: NextRequest) {
|
|
96
|
+
* // Your API logic here
|
|
97
|
+
* return NextResponse.json({ success: true });
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* // Apply both rate limiting and CSRF protection
|
|
101
|
+
* export const POST = withRateLimit(withCsrf(sensitiveHandler));
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function withRateLimit(handler) {
|
|
105
|
+
return async (request) => {
|
|
106
|
+
// Resolve once, outside the try, so the catch block reports the same key
|
|
107
|
+
// that was actually consumed rather than recomputing it.
|
|
108
|
+
const ip = clientIp(request);
|
|
109
|
+
if (!ip) {
|
|
110
|
+
// Fail closed. Previously an unresolvable IP collapsed to the literal
|
|
111
|
+
// string 'unknown', putting every such caller in ONE shared bucket — so a
|
|
112
|
+
// single abuser could exhaust the limit for all of them. Refusing is the
|
|
113
|
+
// safer failure, and on Vercel this branch should be unreachable.
|
|
114
|
+
console.warn('Rate limit: could not resolve a trusted client IP; refusing request');
|
|
115
|
+
return NextResponse.json({ error: 'Too many requests, please try again later' }, { status: 429 });
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
// Consume a point for this IP address
|
|
119
|
+
await rateLimiter.consume(ip);
|
|
120
|
+
// Rate limit not exceeded, proceed with the original handler
|
|
121
|
+
return await handler(request);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
// Check if this is a rate limit error.
|
|
125
|
+
//
|
|
126
|
+
// instanceof, NOT `err.constructor.name === 'RateLimiterRes'`. The
|
|
127
|
+
// production server bundle is minified and class names are mangled, so
|
|
128
|
+
// the name check matched in `next dev` and silently failed in `next
|
|
129
|
+
// build` + `next start`: every over-limit request fell through to the
|
|
130
|
+
// rethrow below and surfaced as a 500, and the violation was never
|
|
131
|
+
// logged. Caught by the release smoke test, not by review.
|
|
132
|
+
if (err instanceof RateLimiterRes) {
|
|
133
|
+
const endpoint = request.nextUrl.pathname;
|
|
134
|
+
console.warn(`Rate limit exceeded for IP: ${ip} on ${endpoint}`);
|
|
135
|
+
// Log security violation to Convex. The mutation requires the shared
|
|
136
|
+
// SECURITY_LOG_KEY so that only this server, not anyone holding the
|
|
137
|
+
// public deployment URL, can write to the security log.
|
|
138
|
+
const client = getConvexClient();
|
|
139
|
+
const key = process.env.SECURITY_LOG_KEY;
|
|
140
|
+
if (client && !key) {
|
|
141
|
+
console.error('SECURITY_LOG_KEY is not set; rate limit violation not logged to Convex');
|
|
142
|
+
}
|
|
143
|
+
if (client && key) {
|
|
144
|
+
try {
|
|
145
|
+
await client.mutation(logSecurityViolation, {
|
|
146
|
+
key,
|
|
147
|
+
eventType: 'rate_limit_exceeded',
|
|
148
|
+
severity: 'medium',
|
|
149
|
+
metadata: {
|
|
150
|
+
endpoint,
|
|
151
|
+
ipAddress: ip,
|
|
152
|
+
errorMessage: 'Rate limit exceeded: 5 requests per minute',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch (logErr) {
|
|
157
|
+
console.error('Failed to log rate limit violation:', logErr);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return NextResponse.json({ error: 'Too many requests, please try again later' }, { status: 429 });
|
|
161
|
+
}
|
|
162
|
+
// Re-throw any other errors
|
|
163
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
164
|
+
console.error('Rate limiting error:', errorMessage);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "secure-vibe-runtime",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "7.3.0",
|
|
4
|
+
"description": "The runtime security pieces of Secure Vibe Coding OS (secrets loading, CSRF, rate limiting, security log) as one package, so every app built from the template gets fixes by updating it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Allen Harper",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/harperaa/secure-vibe-coding-OS.git",
|
|
11
|
+
"directory": "packages/secure-vibe-runtime"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./csrf": {
|
|
23
|
+
"types": "./dist/csrf.d.ts",
|
|
24
|
+
"default": "./dist/csrf.js"
|
|
25
|
+
},
|
|
26
|
+
"./secrets": {
|
|
27
|
+
"types": "./dist/secrets.d.ts",
|
|
28
|
+
"default": "./dist/secrets.js"
|
|
29
|
+
},
|
|
30
|
+
"./withCsrf": {
|
|
31
|
+
"types": "./dist/withCsrf.d.ts",
|
|
32
|
+
"default": "./dist/withCsrf.js"
|
|
33
|
+
},
|
|
34
|
+
"./withRateLimit": {
|
|
35
|
+
"types": "./dist/withRateLimit.d.ts",
|
|
36
|
+
"default": "./dist/withRateLimit.js"
|
|
37
|
+
},
|
|
38
|
+
"./convex": {
|
|
39
|
+
"types": "./dist/convex/index.d.ts",
|
|
40
|
+
"default": "./dist/convex/index.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
45
|
+
"prepublishOnly": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"convex": ">=1.31.0",
|
|
49
|
+
"next": ">=15.0.0"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"crypto-js": "^4.2.0",
|
|
53
|
+
"rate-limiter-flexible": "^8.1.0",
|
|
54
|
+
"uuid": "^14.0.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/crypto-js": "^4.2.2",
|
|
58
|
+
"@types/node": "^20",
|
|
59
|
+
"convex": "^1.31.3",
|
|
60
|
+
"next": "^16.4.2",
|
|
61
|
+
"typescript": "^5"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=20"
|
|
65
|
+
},
|
|
66
|
+
"keywords": [
|
|
67
|
+
"security",
|
|
68
|
+
"nextjs",
|
|
69
|
+
"convex",
|
|
70
|
+
"csrf",
|
|
71
|
+
"rate-limit",
|
|
72
|
+
"secure-vibe-coding"
|
|
73
|
+
]
|
|
74
|
+
}
|