siteshield 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # SiteShield
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SiteShield is a reusable Node.js bot-protection library for Next.js. This repository is **only the npm package**: it has no Next.js application, dashboard, API server, or WordPress plugin. Create a separate Next.js project to integrate and test it.
4
+
5
+ The current public release is [`siteshield@0.1.0`](https://www.npmjs.com/package/siteshield). Install it in your Next.js project with `npm install siteshield`.
6
+
7
+ ## Structure
8
+
9
+ ```text
10
+ src/core/ Security decisions, policies and counters
11
+ src/integrations/next.ts Next.js request adapter and optional event reporter
12
+ tsconfig.json Package build configuration
13
+ tsconfig.test.json Test compilation configuration
14
+ package.json npm exports and scripts
15
+ ```
16
+
17
+ `dist/` is generated by the build and contains the code published to npm. `node_modules/` and `dist-test/` are generated local files.
18
+
19
+ ## Integrate in a separate Next.js project
20
+
21
+ For Next.js 16 on Node.js, create `proxy.ts` in that other project:
22
+
23
+ ```ts
24
+ import { NextResponse, type NextRequest, type NextFetchEvent } from 'next/server';
25
+ import { createNextShield } from 'siteshield/next';
26
+
27
+ const shield = createNextShield({
28
+ siteId: 'my-project',
29
+ policy: { mode: 'enforce', maxRequests: 30, windowMs: 60_000 },
30
+ getClientIp: (request) => request.headers.get('x-company-client-ip') ?? undefined,
31
+ });
32
+
33
+ export async function proxy(request: NextRequest, event: NextFetchEvent) {
34
+ return (await shield(request, event)) ?? NextResponse.next();
35
+ }
36
+
37
+ export const config = { matcher: ['/api/:path*', '/login'] };
38
+ ```
39
+
40
+ The IP header is only illustrative. Use it **only** if a trusted reverse proxy overwrites it and direct origin access is prevented. A loopback-only resolver returning `127.0.0.1` is useful for local testing but must never be used in production. Missing trusted IPs and counter failures return 503.
41
+
42
+ Default counters are in memory, suitable for one process and local demonstrations. For multiple instances, pass a shared `RedisCounterStore` from `siteshield/counters`. `mode: 'monitor'` records would-be restrictions without enforcing them; `mode: 'enforce'` returns 429 for rate limits and 403 for blocks. This is not an edge firewall or a substitute for DDoS protection.
43
+
44
+ `createApiEventReporter` sends restricted events to a separately hosted SiteShield API. This repository does not provide or deploy that API. Keep site credentials server-side and never use `NEXT_PUBLIC_` for secrets.
45
+
46
+ ## Develop and release
47
+
48
+ ```powershell
49
+ npm install
50
+ npm test
51
+ npm run build
52
+ npm run pack:package
53
+ ```
54
+
55
+ The package created by `npm run pack:package` can be installed into your separate Next.js project for testing. Published versions are immutable: bump the root `package.json` version before `npm run publish:package`. Publishing needs ownership of the `siteshield` npm package and npm account verification.
@@ -0,0 +1,35 @@
1
+ export declare function counterSitePrefix(siteId: string): string;
2
+ export declare function counterKey(siteId: string, ip: string): string;
3
+ export interface CounterSnapshot {
4
+ requests: number;
5
+ loginAttempts: number;
6
+ }
7
+ export interface CounterStore {
8
+ record(key: string, now: number, requestWindowMs: number, isLogin: boolean, loginWindowMs: number): Promise<CounterSnapshot>;
9
+ reset(prefix?: string): Promise<void>;
10
+ }
11
+ /** Deterministic store for local development and unit tests. */
12
+ export declare class MemoryCounterStore implements CounterStore {
13
+ private readonly entries;
14
+ record(key: string, now: number, requestWindowMs: number, isLogin: boolean, loginWindowMs: number): Promise<CounterSnapshot>;
15
+ reset(prefix?: string): Promise<void>;
16
+ }
17
+ export interface RedisEvalClient {
18
+ eval(script: string, options: {
19
+ keys: string[];
20
+ arguments: string[];
21
+ }): Promise<unknown>;
22
+ scanIterator(options: {
23
+ MATCH: string;
24
+ COUNT: number;
25
+ }): AsyncIterable<string[]>;
26
+ del(key: string): Promise<number>;
27
+ }
28
+ /** Redis-backed sliding windows. The Lua script makes trim/add/count atomic across API instances. */
29
+ export declare class RedisCounterStore implements CounterStore {
30
+ private readonly client;
31
+ private readonly prefix;
32
+ constructor(client: RedisEvalClient, prefix?: string);
33
+ record(key: string, now: number, requestWindowMs: number, isLogin: boolean, loginWindowMs: number): Promise<CounterSnapshot>;
34
+ reset(prefix?: string): Promise<void>;
35
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ export function counterSitePrefix(siteId) {
3
+ return `${createHash("sha256").update(siteId).digest("hex")}:`;
4
+ }
5
+ export function counterKey(siteId, ip) {
6
+ return `${counterSitePrefix(siteId)}${createHash("sha256").update(ip).digest("hex")}`;
7
+ }
8
+ /** Deterministic store for local development and unit tests. */
9
+ export class MemoryCounterStore {
10
+ entries = new Map();
11
+ async record(key, now, requestWindowMs, isLogin, loginWindowMs) {
12
+ const entry = this.entries.get(key) ?? { requests: [], loginAttempts: [] };
13
+ entry.requests = entry.requests.filter((at) => now - at < requestWindowMs);
14
+ entry.loginAttempts = entry.loginAttempts.filter((at) => now - at < loginWindowMs);
15
+ entry.requests.push(now);
16
+ if (isLogin)
17
+ entry.loginAttempts.push(now);
18
+ this.entries.set(key, entry);
19
+ return {
20
+ requests: entry.requests.length,
21
+ loginAttempts: entry.loginAttempts.length,
22
+ };
23
+ }
24
+ async reset(prefix) {
25
+ if (!prefix)
26
+ this.entries.clear();
27
+ else
28
+ for (const key of this.entries.keys())
29
+ if (key.startsWith(prefix))
30
+ this.entries.delete(key);
31
+ }
32
+ }
33
+ /** Redis-backed sliding windows. The Lua script makes trim/add/count atomic across API instances. */
34
+ export class RedisCounterStore {
35
+ client;
36
+ prefix;
37
+ constructor(client, prefix = "siteshield:counter:") {
38
+ this.client = client;
39
+ this.prefix = prefix;
40
+ if (!/^[a-zA-Z0-9:_-]+$/.test(prefix))
41
+ throw new Error("Invalid Redis counter namespace");
42
+ }
43
+ async record(key, now, requestWindowMs, isLogin, loginWindowMs) {
44
+ const requestKey = `${this.prefix}{${key}}:requests`;
45
+ const loginKey = `${this.prefix}{${key}}:logins`;
46
+ const script = `
47
+ local clock = redis.call('TIME')
48
+ local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
49
+ local requestWindow = tonumber(ARGV[2])
50
+ local loginWindow = tonumber(ARGV[3])
51
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - requestWindow)
52
+ redis.call('ZADD', KEYS[1], now, ARGV[4])
53
+ redis.call('EXPIRE', KEYS[1], math.ceil(requestWindow / 1000) + 60)
54
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - loginWindow)
55
+ if ARGV[5] == '1' then
56
+ redis.call('ZADD', KEYS[2], now, ARGV[4])
57
+ redis.call('EXPIRE', KEYS[2], math.ceil(loginWindow / 1000) + 60)
58
+ end
59
+ return { redis.call('ZCARD', KEYS[1]), redis.call('ZCARD', KEYS[2]) }
60
+ `;
61
+ const result = await this.client.eval(script, {
62
+ keys: [requestKey, loginKey],
63
+ arguments: [
64
+ String(now),
65
+ String(requestWindowMs),
66
+ String(loginWindowMs),
67
+ `${now}:${randomUUID()}`,
68
+ isLogin ? "1" : "0",
69
+ ],
70
+ });
71
+ if (!Array.isArray(result) ||
72
+ result.length !== 2 ||
73
+ result.some((value) => !Number.isSafeInteger(value) || Number(value) < 0))
74
+ throw new Error("Invalid Redis counter response");
75
+ const values = result;
76
+ return { requests: Number(values[0]), loginAttempts: Number(values[1]) };
77
+ }
78
+ async reset(prefix) {
79
+ if (prefix && !/^[a-f0-9]{64}:$/.test(prefix))
80
+ throw new Error("Invalid counter reset prefix");
81
+ const pattern = prefix ? `${this.prefix}{${prefix}*}:*` : `${this.prefix}*`;
82
+ for await (const keys of this.client.scanIterator({
83
+ MATCH: pattern,
84
+ COUNT: 100,
85
+ })) {
86
+ // Delete individually to remain compatible with Redis Cluster hash slots.
87
+ for (const key of keys)
88
+ await this.client.del(key);
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,9 @@
1
+ import type { Evaluation, RequestContext, SitePolicy } from "./types.js";
2
+ import { type CounterStore } from "./counters.js";
3
+ export declare class SecurityEngine {
4
+ private readonly policy;
5
+ private readonly counters;
6
+ constructor(policy?: Partial<SitePolicy>, counters?: CounterStore);
7
+ evaluate(request: RequestContext): Promise<Evaluation>;
8
+ reset(siteId?: string): Promise<void>;
9
+ }
@@ -0,0 +1,78 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { counterKey, counterSitePrefix, MemoryCounterStore } from "./counters.js";
3
+ const defaultPolicy = {
4
+ mode: "enforce",
5
+ windowMs: 60_000,
6
+ maxRequests: 60,
7
+ loginPaths: ["/wp-login.php", "/wp-admin", "/api/auth/login", "/login"],
8
+ loginWindowMs: 60_000,
9
+ maxLoginAttempts: 8,
10
+ blockScore: 5,
11
+ limitScore: 3,
12
+ };
13
+ export class SecurityEngine {
14
+ policy;
15
+ counters;
16
+ constructor(policy = {}, counters = new MemoryCounterStore()) {
17
+ this.policy = { ...defaultPolicy, ...policy };
18
+ this.counters = counters;
19
+ }
20
+ async evaluate(request) {
21
+ const now = request.now ?? Date.now();
22
+ const key = counterKey(request.siteId, request.ip);
23
+ const isLogin = this.policy.loginPaths.some((path) => request.path === path || request.path.startsWith(`${path}/`));
24
+ const counter = await this.counters.record(key, now, this.policy.windowMs, isLogin, this.policy.loginWindowMs);
25
+ const reasons = [];
26
+ let score = 0;
27
+ const crawler = this.policy.trustedCrawler
28
+ ? await this.policy.trustedCrawler(request)
29
+ : false;
30
+ if (crawler) {
31
+ reasons.push("verified crawler");
32
+ }
33
+ else {
34
+ if (counter.requests > this.policy.maxRequests) {
35
+ score += 3;
36
+ reasons.push("request rate exceeded");
37
+ }
38
+ if (isLogin && counter.loginAttempts > this.policy.maxLoginAttempts) {
39
+ score += 4;
40
+ reasons.push("login attempt rate exceeded");
41
+ }
42
+ if (!request.userAgent ||
43
+ /bot|crawler|spider|scrapy|curl|python-requests/i.test(request.userAgent)) {
44
+ score += 1;
45
+ reasons.push("automated user agent");
46
+ }
47
+ if (request.method.toUpperCase() === "POST" && isLogin)
48
+ score += 1;
49
+ }
50
+ let decision = score >= this.policy.blockScore
51
+ ? "block"
52
+ : score >= this.policy.limitScore
53
+ ? "limit"
54
+ : "allow";
55
+ if (this.policy.mode === "monitor" && decision !== "allow")
56
+ decision = "monitor";
57
+ const event = {
58
+ id: randomUUID(),
59
+ siteId: request.siteId,
60
+ timestamp: new Date(now).toISOString(),
61
+ request: {
62
+ siteId: request.siteId,
63
+ ip: request.ip,
64
+ method: request.method,
65
+ path: request.path,
66
+ userAgent: request.userAgent,
67
+ },
68
+ decision,
69
+ score,
70
+ reasons,
71
+ counters: counter,
72
+ };
73
+ return { decision, score, reasons, event };
74
+ }
75
+ reset(siteId) {
76
+ return this.counters.reset(siteId ? counterSitePrefix(siteId) : undefined);
77
+ }
78
+ }
@@ -0,0 +1,39 @@
1
+ export type Decision = "allow" | "monitor" | "limit" | "block";
2
+ export interface RequestContext {
3
+ siteId: string;
4
+ ip: string;
5
+ method: string;
6
+ path: string;
7
+ userAgent?: string;
8
+ now?: number;
9
+ }
10
+ export interface SitePolicy {
11
+ mode: "monitor" | "enforce";
12
+ windowMs: number;
13
+ maxRequests: number;
14
+ loginPaths: string[];
15
+ loginWindowMs: number;
16
+ maxLoginAttempts: number;
17
+ blockScore: number;
18
+ limitScore: number;
19
+ trustedCrawler?: (request: RequestContext) => boolean | Promise<boolean>;
20
+ }
21
+ export interface SecurityEvent {
22
+ id: string;
23
+ siteId: string;
24
+ timestamp: string;
25
+ request: Omit<RequestContext, "now">;
26
+ decision: Decision;
27
+ score: number;
28
+ reasons: string[];
29
+ counters: {
30
+ requests: number;
31
+ loginAttempts: number;
32
+ };
33
+ }
34
+ export interface Evaluation {
35
+ decision: Decision;
36
+ score: number;
37
+ reasons: string[];
38
+ event: SecurityEvent;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { CounterStore } from '../core/counters.js';
2
+ import type { Evaluation, SitePolicy } from '../core/types.js';
3
+ export interface NextShieldOptions {
4
+ siteId: string;
5
+ policy?: Partial<SitePolicy>;
6
+ counterStore?: CounterStore;
7
+ eventReporter?: (evaluation: Evaluation) => Promise<void>;
8
+ /** Must return an IP from a source sanitized by your hosting platform, never arbitrary client headers. */
9
+ getClientIp: (request: Request) => string | undefined;
10
+ }
11
+ /** Use this from Next.js middleware/proxy. Return undefined to continue the request. */
12
+ export declare function createNextShield(options: NextShieldOptions): (request: Request, event?: {
13
+ waitUntil(promise: Promise<unknown>): void;
14
+ }) => Promise<Response | undefined>;
15
+ export declare function createApiEventReporter(apiUrl: string, siteKey: string): (evaluation: Evaluation) => Promise<void>;
@@ -0,0 +1,74 @@
1
+ import { SecurityEngine } from '../core/engine.js';
2
+ import { isIP } from 'node:net';
3
+ function requestContext(request, siteId, ip) {
4
+ const url = new URL(request.url);
5
+ return {
6
+ siteId,
7
+ ip,
8
+ method: request.method,
9
+ path: url.pathname,
10
+ userAgent: request.headers.get('user-agent') ?? undefined
11
+ };
12
+ }
13
+ function enforcementResponse(evaluation, retryAfter) {
14
+ if (evaluation.decision === 'limit') {
15
+ return new Response(JSON.stringify({ error: 'rate_limited', requestId: evaluation.event.id }), {
16
+ status: 429,
17
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'retry-after': String(retryAfter), 'x-siteshield-decision': 'limit' }
18
+ });
19
+ }
20
+ return new Response(JSON.stringify({ error: 'request_blocked', requestId: evaluation.event.id }), {
21
+ status: 403,
22
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-siteshield-decision': 'block' }
23
+ });
24
+ }
25
+ /** Use this from Next.js middleware/proxy. Return undefined to continue the request. */
26
+ export function createNextShield(options) {
27
+ if (!options.siteId?.trim())
28
+ throw new Error('siteId is required');
29
+ if (!options.getClientIp)
30
+ throw new Error('getClientIp is required');
31
+ const engine = new SecurityEngine(options.policy, options.counterStore);
32
+ const retryAfter = Math.ceil((options.policy?.windowMs ?? 60_000) / 1000);
33
+ return async function siteShield(request, event) {
34
+ let evaluation;
35
+ try {
36
+ const ip = options.getClientIp(request);
37
+ if (!ip || !isIP(ip))
38
+ throw new Error('A trusted client IP is required');
39
+ evaluation = await engine.evaluate(requestContext(request, options.siteId, ip));
40
+ }
41
+ catch {
42
+ // Counter/identity failures are not disguised as successful protection.
43
+ return Response.json({ error: 'protection_unavailable' }, { status: 503, headers: { 'cache-control': 'no-store' } });
44
+ }
45
+ if (options.eventReporter && evaluation.decision !== 'allow') {
46
+ const report = Promise.resolve().then(() => options.eventReporter(evaluation)).catch(() => undefined);
47
+ event?.waitUntil(report);
48
+ }
49
+ if (evaluation.decision === 'allow' || evaluation.decision === 'monitor')
50
+ return undefined;
51
+ return enforcementResponse(evaluation, retryAfter);
52
+ };
53
+ }
54
+ export function createApiEventReporter(apiUrl, siteKey) {
55
+ const url = new URL(apiUrl);
56
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
57
+ throw new Error('Invalid API URL');
58
+ if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
59
+ throw new Error('Use HTTPS for non-local API URLs');
60
+ if (!siteKey?.trim())
61
+ throw new Error('siteKey is required');
62
+ const endpoint = new URL('/v1/events', apiUrl).toString();
63
+ return async (evaluation) => {
64
+ const response = await fetch(endpoint, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json', 'x-siteshield-key': siteKey },
67
+ body: JSON.stringify(evaluation.event),
68
+ signal: AbortSignal.timeout(1500),
69
+ redirect: 'error'
70
+ });
71
+ if (!response.ok)
72
+ throw new Error(`SiteShield event API returned ${response.status}`);
73
+ };
74
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "siteshield",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Reusable bot-protection engine and Next.js adapter",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "exports": {
13
+ "./next": {
14
+ "types": "./dist/integrations/next.d.ts",
15
+ "import": "./dist/integrations/next.js"
16
+ },
17
+ "./counters": {
18
+ "types": "./dist/core/counters.d.ts",
19
+ "import": "./dist/core/counters.js"
20
+ },
21
+ "./core": {
22
+ "types": "./dist/core/engine.d.ts",
23
+ "import": "./dist/core/engine.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "clean": "node -e \"for (const p of ['dist','dist-test']) require('node:fs').rmSync(p,{recursive:true,force:true})\"",
31
+ "build": "npm run clean && tsc -p tsconfig.json",
32
+ "build:package": "npm run build",
33
+ "pack:package": "npm run build && npm pack",
34
+ "publish:package": "npm run build && npm publish --access public",
35
+ "test": "tsc -p tsconfig.test.json && node --test dist-test/core/*.test.js dist-test/integrations/*.test.js",
36
+ "prepack": "npm run build"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.20.5",
40
+ "typescript": "^5.7.2"
41
+ }
42
+ }