@nascencestudio/ipguard 0.0.0-stage → 0.1.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/dist/abuseipdb.d.ts +42 -0
  4. package/dist/abuseipdb.d.ts.map +1 -0
  5. package/dist/abuseipdb.js +84 -0
  6. package/dist/abuseipdb.js.map +1 -0
  7. package/dist/admin.d.ts +41 -0
  8. package/dist/admin.d.ts.map +1 -0
  9. package/dist/admin.js +87 -0
  10. package/dist/admin.js.map +1 -0
  11. package/dist/index.d.ts +33 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +102 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/ip.d.ts +54 -0
  16. package/dist/ip.d.ts.map +1 -0
  17. package/dist/ip.js +218 -0
  18. package/dist/ip.js.map +1 -0
  19. package/dist/model.d.ts +94 -0
  20. package/dist/model.d.ts.map +1 -0
  21. package/dist/model.js +148 -0
  22. package/dist/model.js.map +1 -0
  23. package/dist/runtime/IpGuardNav.astro +63 -0
  24. package/dist/runtime/IpGuardPage.astro +408 -0
  25. package/dist/runtime/admin-endpoint.d.ts +10 -0
  26. package/dist/runtime/admin-endpoint.d.ts.map +1 -0
  27. package/dist/runtime/admin-endpoint.js +168 -0
  28. package/dist/runtime/admin-endpoint.js.map +1 -0
  29. package/dist/runtime/guard.d.ts +44 -0
  30. package/dist/runtime/guard.d.ts.map +1 -0
  31. package/dist/runtime/guard.js +136 -0
  32. package/dist/runtime/guard.js.map +1 -0
  33. package/dist/runtime/middleware.d.ts +8 -0
  34. package/dist/runtime/middleware.d.ts.map +1 -0
  35. package/dist/runtime/middleware.js +27 -0
  36. package/dist/runtime/middleware.js.map +1 -0
  37. package/dist/runtime/store.d.ts +22 -0
  38. package/dist/runtime/store.d.ts.map +1 -0
  39. package/dist/runtime/store.js +194 -0
  40. package/dist/runtime/store.js.map +1 -0
  41. package/dist/runtime/viewer.d.ts +9 -0
  42. package/dist/runtime/viewer.d.ts.map +1 -0
  43. package/dist/runtime/viewer.js +29 -0
  44. package/dist/runtime/viewer.js.map +1 -0
  45. package/dist/virtual.d.ts +12 -0
  46. package/package.json +65 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nascence Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @nascencestudio/ipguard
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
+ **IP protection for [StudioCMS](https://studiocms.dev) sites:** review lists, rate limiting,
4
+ [AbuseIPDB](https://www.abuseipdb.com/) reputation checks and site-wide bans, managed in the dashboard.
5
+ Built for [@nascencestudio/webforms](https://github.com/nascencestudio/webforms), and usable by any plugin.
6
+
7
+ - **Four lists**, each entry a single address or a range (`203.0.113.0/24`, `2001:db8::/48`):
8
+ - **Whitelist**: never checked, never blocked (your office, for testing).
9
+ - **Flagged**: went over the rate limit; **blocked until you review it**: ban, blacklist, whitelist or
10
+ dismiss.
11
+ - **Blacklist**: can't submit forms; the site stays reachable. A block can end on a date.
12
+ - **Banned**: gets "Access denied" for every page.
13
+ - **For each protected action** (a form submission): whitelisted → allowed; banned, blacklisted or
14
+ flagged → refused; otherwise the rate limit and the AbuseIPDB score decide: a high score **and** over the
15
+ limit → banned; a high score → blacklisted; over the limit → flagged; else allowed. IPv6 visitors are
16
+ grouped by their /64.
17
+ - **AbuseIPDB done carefully:** the key travels in a header, lookups time out after 5 seconds, scores are
18
+ kept 24 hours, private addresses are never sent, and if AbuseIPDB can't answer nobody is blocked. After a
19
+ refused key or a used-up daily quota, lookups pause instead of slowing every form down.
20
+ - **You can't lock yourself out:** your own address and whitelisted ones can't be banned, blacklisted or
21
+ flagged. If it happens anyway, `IPGUARD_DISABLE=1` turns all blocking off.
22
+ - **Check an address:** what would happen to a submission from an address, step by step.
23
+
24
+ ## Requirements
25
+
26
+ - Node ≥ 22.12, Astro 7, StudioCMS 0.6 or 0.7
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pnpm add @nascencestudio/ipguard
32
+ ```
33
+
34
+ ```js
35
+ // studiocms.config.mjs
36
+ import ipguard from '@nascencestudio/ipguard';
37
+ export default defineStudioCMSConfig({ plugins: [ipguard()] });
38
+ ```
39
+
40
+ Then **Dashboard → Admin → IP Guard**. For AbuseIPDB, get a free key at
41
+ [abuseipdb.com/account/api](https://www.abuseipdb.com/account/api) and set it as the `ABUSEIPDB_API_KEY`
42
+ environment variable (recommended), or in IP Guard's Settings. Mention the check in your privacy policy:
43
+ visitors' addresses are sent to AbuseIPDB when they submit a protected form.
44
+
45
+ **Behind a reverse proxy** (Caddy, nginx), IP Guard reads the visitor's address from `X-Forwarded-For`,
46
+ but only when the request comes from a trusted proxy. By default those are loopback and private
47
+ networks, which covers a proxy on the same machine or Docker network. Otherwise list yours:
48
+
49
+ ```js
50
+ ipguard({ trustedProxies: ['10.0.0.5'] })
51
+ ```
52
+
53
+ ## For plugin authors
54
+
55
+ ```ts
56
+ import { checkAction, recordAttempt } from '@nascencestudio/ipguard/guard';
57
+
58
+ const result = await checkAction(Astro, { key: 'form:contact' });
59
+ if (result.outcome === 'refuse') return new Response(result.message, { status: 403 });
60
+ // Something your own checks rejected (it counts towards the rate limit):
61
+ await recordAttempt(Astro);
62
+ ```
63
+
64
+ ## How it's built
65
+
66
+ IP Guard is developed with an AI coding assistant (Anthropic's Claude), directed by its maintainer at
67
+ Nascence Studio, who sets the requirements, makes the decisions and checks the results. Its design follows
68
+ the maintainer's own Drupal module. Commits written with the assistant's help say so (`Co-Authored-By`).
69
+
70
+ - **Decisions are written down** in [docs/decisions](docs/decisions/) and the [devlog](docs/devlog.md).
71
+ - **Behaviour is tested:** unit tests cover address parsing (IPv4, IPv6, ranges, forged headers), the
72
+ decision table, the lockout guards and every AbuseIPDB failure path; browser tests in
73
+ [tapestry-playground](https://github.com/nascencestudio/tapestry-playground) drive the dashboard, bans,
74
+ flagging, reviews and an accessibility audit.
75
+ - **No runtime dependencies.** Releases are published from GitHub Actions with npm provenance and approved
76
+ with two-factor authentication.
77
+
78
+ ## License
79
+
80
+ MIT © Nascence Studio
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The AbuseIPDB client (ADR 0001 §4), after the user's Drupal module: the key goes in a request header
3
+ * (never in a URL or a log), requests time out after 5 seconds, scores are cached for 24 hours per
4
+ * address, private and reserved addresses are never sent, and every failure returns null: **fail open**,
5
+ * the caller doesn't block. After a 401 (bad key) or 429 (daily quota used up) lookups pause for the
6
+ * `Retry-After` time (1 minute to 24 hours; 1 hour without one), so an attacker can't burn the quota to
7
+ * slow every form down. The network and the cache are passed in (tests use fakes).
8
+ */
9
+ import { type Ip } from './ip.js';
10
+ export declare const ENDPOINT = "https://api.abuseipdb.com/api/v2/check";
11
+ export declare const CACHE_SECONDS = 86400;
12
+ export interface ScoreStore {
13
+ /** A cached score that hasn't expired, or null. */
14
+ getScore(ip: string): Promise<number | null>;
15
+ setScore(ip: string, score: number, expiresAt: number): Promise<void>;
16
+ /** When the pause ends (ms), or null. */
17
+ getBackoff(): Promise<number | null>;
18
+ setBackoff(until: number): Promise<void>;
19
+ }
20
+ export interface AbuseIpDbOptions {
21
+ fetch: typeof globalThis.fetch;
22
+ store: ScoreStore;
23
+ now?: () => number;
24
+ endpoint?: string;
25
+ log?: (level: 'warn' | 'error', message: string) => void;
26
+ }
27
+ export type CheckResult = {
28
+ score: number;
29
+ cached: boolean;
30
+ } | {
31
+ score: null;
32
+ why: 'not-public' | 'no-key' | 'paused' | 'failed';
33
+ };
34
+ export declare function createAbuseIpDb(options: AbuseIpDbOptions): {
35
+ check: (ip: Ip, apiKey: string) => Promise<CheckResult>;
36
+ /** When paused lookups resume (ms), or null. */
37
+ pausedUntil: () => Promise<number | null>;
38
+ /** Resume lookups now (after fixing the key, or a new day's quota). */
39
+ resume: () => Promise<void>;
40
+ };
41
+ export type AbuseIpDb = ReturnType<typeof createAbuseIpDb>;
42
+ //# sourceMappingURL=abuseipdb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abuseipdb.d.ts","sourceRoot":"","sources":["../src/abuseipdb.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAY,KAAK,EAAE,EAAY,MAAM,SAAS,CAAC;AAEtD,eAAO,MAAM,QAAQ,2CAA2C,CAAC;AACjE,eAAO,MAAM,aAAa,QAAS,CAAC;AAOpC,MAAM,WAAW,UAAU;IAC1B,mDAAmD;IACnD,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7C,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,yCAAyC;IACzC,UAAU,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACrC,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAC/B,KAAK,EAAE,UAAU,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACzD;AAED,MAAM,MAAM,WAAW,GACpB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAClC;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,YAAY,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;CAAE,CAAC;AAEvE,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB;gBAa/B,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,WAAW,CAAC;IA+CjE,gDAAgD;;IAKhD,uEAAuE;;EAGxE;AAED,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The AbuseIPDB client (ADR 0001 §4), after the user's Drupal module: the key goes in a request header
3
+ * (never in a URL or a log), requests time out after 5 seconds, scores are cached for 24 hours per
4
+ * address, private and reserved addresses are never sent, and every failure returns null: **fail open**,
5
+ * the caller doesn't block. After a 401 (bad key) or 429 (daily quota used up) lookups pause for the
6
+ * `Retry-After` time (1 minute to 24 hours; 1 hour without one), so an attacker can't burn the quota to
7
+ * slow every form down. The network and the cache are passed in (tests use fakes).
8
+ */
9
+ import { formatIp, isPublic } from './ip.js';
10
+ export const ENDPOINT = 'https://api.abuseipdb.com/api/v2/check';
11
+ export const CACHE_SECONDS = 86_400;
12
+ const MAX_AGE_DAYS = 30;
13
+ const TIMEOUT_MS = 5000;
14
+ const BACKOFF_DEFAULT = 3600;
15
+ const BACKOFF_MIN = 60;
16
+ const BACKOFF_MAX = 86_400;
17
+ export function createAbuseIpDb(options) {
18
+ const now = options.now ?? Date.now;
19
+ const endpoint = options.endpoint ?? ENDPOINT;
20
+ const log = options.log ?? ((level, message) => console[level](`[ipguard] ${message}`));
21
+ async function pause(retryAfter) {
22
+ const seconds = retryAfter && /^[0-9]{1,6}$/.test(retryAfter) ? Number(retryAfter) : BACKOFF_DEFAULT;
23
+ const clamped = Math.max(BACKOFF_MIN, Math.min(seconds, BACKOFF_MAX));
24
+ await options.store.setBackoff(now() + clamped * 1000);
25
+ log('warn', `AbuseIPDB lookups paused for ${clamped} s; forms aren't blocked by AbuseIPDB meanwhile.`);
26
+ }
27
+ /** The address's abuse confidence score (0–100), or why there's none. */
28
+ async function check(ip, apiKey) {
29
+ if (!isPublic(ip))
30
+ return { score: null, why: 'not-public' };
31
+ const address = formatIp(ip);
32
+ const cached = await options.store.getScore(address).catch(() => null);
33
+ if (cached !== null)
34
+ return { score: cached, cached: true };
35
+ if (!apiKey)
36
+ return { score: null, why: 'no-key' };
37
+ const until = await options.store.getBackoff().catch(() => null);
38
+ if (until !== null && until > now())
39
+ return { score: null, why: 'paused' };
40
+ const url = `${endpoint}?${new URLSearchParams({ ipAddress: address, maxAgeInDays: String(MAX_AGE_DAYS) })}`;
41
+ try {
42
+ const response = await options.fetch(url, {
43
+ headers: { Key: apiKey, Accept: 'application/json' },
44
+ signal: AbortSignal.timeout(TIMEOUT_MS),
45
+ redirect: 'error',
46
+ });
47
+ if (response.status === 401 || response.status === 429) {
48
+ log('warn', response.status === 401
49
+ ? 'AbuseIPDB refused the API key (HTTP 401): check it in IP Guard settings.'
50
+ : 'AbuseIPDB daily limit reached (HTTP 429).');
51
+ await pause(response.headers.get('retry-after'));
52
+ return { score: null, why: 'failed' };
53
+ }
54
+ if (!response.ok) {
55
+ log('warn', `AbuseIPDB answered HTTP ${response.status} for ${address}.`);
56
+ return { score: null, why: 'failed' };
57
+ }
58
+ const body = (await response.json());
59
+ const score = body?.data?.abuseConfidenceScore;
60
+ if (typeof score !== 'number' || !Number.isInteger(score) || score < 0 || score > 100) {
61
+ log('warn', `AbuseIPDB sent no valid score for ${address}.`);
62
+ return { score: null, why: 'failed' };
63
+ }
64
+ await options.store.setScore(address, score, now() + CACHE_SECONDS * 1000).catch(() => { });
65
+ return { score, cached: false };
66
+ }
67
+ catch (error) {
68
+ // Timeouts, network errors, non-JSON bodies. The message never contains the key.
69
+ log('error', `AbuseIPDB lookup for ${address} failed: ${error.name}`);
70
+ return { score: null, why: 'failed' };
71
+ }
72
+ }
73
+ return {
74
+ check,
75
+ /** When paused lookups resume (ms), or null. */
76
+ pausedUntil: async () => {
77
+ const until = await options.store.getBackoff().catch(() => null);
78
+ return until !== null && until > now() ? until : null;
79
+ },
80
+ /** Resume lookups now (after fixing the key, or a new day's quota). */
81
+ resume: () => options.store.setBackoff(0),
82
+ };
83
+ }
84
+ //# sourceMappingURL=abuseipdb.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abuseipdb.js","sourceRoot":"","sources":["../src/abuseipdb.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The AbuseIPDB client (ADR 0001 §4), after the user's Drupal module: the key goes in a request header\n * (never in a URL or a log), requests time out after 5 seconds, scores are cached for 24 hours per\n * address, private and reserved addresses are never sent, and every failure returns null: **fail open**,\n * the caller doesn't block. After a 401 (bad key) or 429 (daily quota used up) lookups pause for the\n * `Retry-After` time (1 minute to 24 hours; 1 hour without one), so an attacker can't burn the quota to\n * slow every form down. The network and the cache are passed in (tests use fakes).\n */\nimport { formatIp, type Ip, isPublic } from './ip.js';\n\nexport const ENDPOINT = 'https://api.abuseipdb.com/api/v2/check';\nexport const CACHE_SECONDS = 86_400;\nconst MAX_AGE_DAYS = 30;\nconst TIMEOUT_MS = 5000;\nconst BACKOFF_DEFAULT = 3600;\nconst BACKOFF_MIN = 60;\nconst BACKOFF_MAX = 86_400;\n\nexport interface ScoreStore {\n\t/** A cached score that hasn't expired, or null. */\n\tgetScore(ip: string): Promise<number | null>;\n\tsetScore(ip: string, score: number, expiresAt: number): Promise<void>;\n\t/** When the pause ends (ms), or null. */\n\tgetBackoff(): Promise<number | null>;\n\tsetBackoff(until: number): Promise<void>;\n}\n\nexport interface AbuseIpDbOptions {\n\tfetch: typeof globalThis.fetch;\n\tstore: ScoreStore;\n\tnow?: () => number;\n\tendpoint?: string;\n\tlog?: (level: 'warn' | 'error', message: string) => void;\n}\n\nexport type CheckResult =\n\t| { score: number; cached: boolean }\n\t| { score: null; why: 'not-public' | 'no-key' | 'paused' | 'failed' };\n\nexport function createAbuseIpDb(options: AbuseIpDbOptions) {\n\tconst now = options.now ?? Date.now;\n\tconst endpoint = options.endpoint ?? ENDPOINT;\n\tconst log = options.log ?? ((level, message) => console[level](`[ipguard] ${message}`));\n\n\tasync function pause(retryAfter: string | null): Promise<void> {\n\t\tconst seconds = retryAfter && /^[0-9]{1,6}$/.test(retryAfter) ? Number(retryAfter) : BACKOFF_DEFAULT;\n\t\tconst clamped = Math.max(BACKOFF_MIN, Math.min(seconds, BACKOFF_MAX));\n\t\tawait options.store.setBackoff(now() + clamped * 1000);\n\t\tlog('warn', `AbuseIPDB lookups paused for ${clamped} s; forms aren't blocked by AbuseIPDB meanwhile.`);\n\t}\n\n\t/** The address's abuse confidence score (0–100), or why there's none. */\n\tasync function check(ip: Ip, apiKey: string): Promise<CheckResult> {\n\t\tif (!isPublic(ip)) return { score: null, why: 'not-public' };\n\t\tconst address = formatIp(ip);\n\t\tconst cached = await options.store.getScore(address).catch(() => null);\n\t\tif (cached !== null) return { score: cached, cached: true };\n\t\tif (!apiKey) return { score: null, why: 'no-key' };\n\t\tconst until = await options.store.getBackoff().catch(() => null);\n\t\tif (until !== null && until > now()) return { score: null, why: 'paused' };\n\n\t\tconst url = `${endpoint}?${new URLSearchParams({ ipAddress: address, maxAgeInDays: String(MAX_AGE_DAYS) })}`;\n\t\ttry {\n\t\t\tconst response = await options.fetch(url, {\n\t\t\t\theaders: { Key: apiKey, Accept: 'application/json' },\n\t\t\t\tsignal: AbortSignal.timeout(TIMEOUT_MS),\n\t\t\t\tredirect: 'error',\n\t\t\t});\n\t\t\tif (response.status === 401 || response.status === 429) {\n\t\t\t\tlog(\n\t\t\t\t\t'warn',\n\t\t\t\t\tresponse.status === 401\n\t\t\t\t\t\t? 'AbuseIPDB refused the API key (HTTP 401): check it in IP Guard settings.'\n\t\t\t\t\t\t: 'AbuseIPDB daily limit reached (HTTP 429).',\n\t\t\t\t);\n\t\t\t\tawait pause(response.headers.get('retry-after'));\n\t\t\t\treturn { score: null, why: 'failed' };\n\t\t\t}\n\t\t\tif (!response.ok) {\n\t\t\t\tlog('warn', `AbuseIPDB answered HTTP ${response.status} for ${address}.`);\n\t\t\t\treturn { score: null, why: 'failed' };\n\t\t\t}\n\t\t\tconst body = (await response.json()) as { data?: { abuseConfidenceScore?: unknown } };\n\t\t\tconst score = body?.data?.abuseConfidenceScore;\n\t\t\tif (typeof score !== 'number' || !Number.isInteger(score) || score < 0 || score > 100) {\n\t\t\t\tlog('warn', `AbuseIPDB sent no valid score for ${address}.`);\n\t\t\t\treturn { score: null, why: 'failed' };\n\t\t\t}\n\t\t\tawait options.store.setScore(address, score, now() + CACHE_SECONDS * 1000).catch(() => {});\n\t\t\treturn { score, cached: false };\n\t\t} catch (error) {\n\t\t\t// Timeouts, network errors, non-JSON bodies. The message never contains the key.\n\t\t\tlog('error', `AbuseIPDB lookup for ${address} failed: ${(error as Error).name}`);\n\t\t\treturn { score: null, why: 'failed' };\n\t\t}\n\t}\n\n\treturn {\n\t\tcheck,\n\t\t/** When paused lookups resume (ms), or null. */\n\t\tpausedUntil: async () => {\n\t\t\tconst until = await options.store.getBackoff().catch(() => null);\n\t\t\treturn until !== null && until > now() ? until : null;\n\t\t},\n\t\t/** Resume lookups now (after fixing the key, or a new day's quota). */\n\t\tresume: () => options.store.setBackoff(0),\n\t};\n}\n\nexport type AbuseIpDb = ReturnType<typeof createAbuseIpDb>;\n"]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The dashboard's form actions as data (ADR 0001 §7): parsing what an admin submitted, and the error
3
+ * codes the dashboard explains. Pure; the endpoint (runtime/admin-endpoint.ts) applies the results.
4
+ */
5
+ import { type Cidr } from './ip.js';
6
+ import { type ListName, type Settings } from './model.js';
7
+ export declare const ERRORS: {
8
+ readonly 'bad-range': "That isn’t a valid address or range (e.g. 203.0.113.7, 203.0.113.0/24 or 2001:db8::/48).";
9
+ readonly 'too-broad': "That range is too broad (IPv4 at least /8, IPv6 at least /16).";
10
+ readonly 'bad-date': "The expiry date must be a valid date in the future.";
11
+ readonly exists: "That address or range is already on this list.";
12
+ readonly 'own-address': "That includes your own current address: you would lock yourself out.";
13
+ readonly whitelisted: "That includes a whitelisted address. Remove it from the whitelist first.";
14
+ readonly 'nothing-selected': "Nothing was selected.";
15
+ readonly 'bad-settings': "Some settings weren’t valid; nothing was saved.";
16
+ readonly 'bad-key': "An AbuseIPDB key has only letters and digits.";
17
+ readonly unknown: "Unknown action.";
18
+ readonly failed: "That didn’t work. Please try again.";
19
+ };
20
+ export type AdminError = keyof typeof ERRORS;
21
+ export interface AddRequest {
22
+ list: ListName;
23
+ range: Cidr;
24
+ reason: string;
25
+ /** Blacklist only: ISO time the block ends (end of that day, UTC), or null. */
26
+ expiresAt: string | null;
27
+ }
28
+ /** "Add an address" → a request, or why not. Flagged entries are only made automatically. */
29
+ export declare function parseAdd(form: FormData, now?: number): AddRequest | {
30
+ error: AdminError;
31
+ };
32
+ /** Selected entry ids (checkboxes named `ids`, or one row's button value), at most 500. */
33
+ export declare function selectedIds(form: FormData): string[];
34
+ /**
35
+ * The settings form → new settings, or an error. The key: a new one replaces the saved one; empty keeps
36
+ * it; "clear" removes it.
37
+ */
38
+ export declare function parseSettingsForm(form: FormData, current: Settings): Settings | {
39
+ error: AdminError;
40
+ };
41
+ //# sourceMappingURL=admin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.d.ts","sourceRoot":"","sources":["../src/admin.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,KAAK,IAAI,EAAa,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAU,KAAK,QAAQ,EAA0B,KAAK,QAAQ,EAAW,MAAM,YAAY,CAAC;AAEnG,eAAO,MAAM,MAAM;;;;;;;;;;;;CAYT,CAAC;AACX,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,MAAM,CAAC;AAK7C,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,IAAI,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAKD,6FAA6F;AAC7F,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG;IAAE,KAAK,EAAE,UAAU,CAAA;CAAE,CAe7F;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,EAAE,CASpD;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,GAAG,QAAQ,GAAG;IAAE,KAAK,EAAE,UAAU,CAAA;CAAE,CA4BrG"}
package/dist/admin.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The dashboard's form actions as data (ADR 0001 §7): parsing what an admin submitted, and the error
3
+ * codes the dashboard explains. Pure; the endpoint (runtime/admin-endpoint.ts) applies the results.
4
+ */
5
+ import { parseCidr } from './ip.js';
6
+ import { LIMITS, oneLine, parseSettings, WINDOWS } from './model.js';
7
+ export const ERRORS = {
8
+ 'bad-range': 'That isn’t a valid address or range (e.g. 203.0.113.7, 203.0.113.0/24 or 2001:db8::/48).',
9
+ 'too-broad': 'That range is too broad (IPv4 at least /8, IPv6 at least /16).',
10
+ 'bad-date': 'The expiry date must be a valid date in the future.',
11
+ exists: 'That address or range is already on this list.',
12
+ 'own-address': 'That includes your own current address: you would lock yourself out.',
13
+ whitelisted: 'That includes a whitelisted address. Remove it from the whitelist first.',
14
+ 'nothing-selected': 'Nothing was selected.',
15
+ 'bad-settings': 'Some settings weren’t valid; nothing was saved.',
16
+ 'bad-key': 'An AbuseIPDB key has only letters and digits.',
17
+ unknown: 'Unknown action.',
18
+ failed: 'That didn’t work. Please try again.',
19
+ };
20
+ /** Ranges broader than this aren't accepted (no banning half the internet by a typo). */
21
+ const MIN_PREFIX = { 4: 8, 6: 16 };
22
+ const LIST_NAMES = ['whitelist', 'flagged', 'blacklist', 'banned'];
23
+ const isList = (v) => LIST_NAMES.includes(v);
24
+ /** "Add an address" → a request, or why not. Flagged entries are only made automatically. */
25
+ export function parseAdd(form, now = Date.now()) {
26
+ const list = form.get('list');
27
+ if (!isList(list) || list === 'flagged')
28
+ return { error: 'unknown' };
29
+ const range = parseCidr(form.get('range'));
30
+ if (!range)
31
+ return { error: 'bad-range' };
32
+ if (range.prefix < MIN_PREFIX[range.version])
33
+ return { error: 'too-broad' };
34
+ let expiresAt = null;
35
+ const expires = form.get('expires');
36
+ if (list === 'blacklist' && typeof expires === 'string' && expires !== '') {
37
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(expires))
38
+ return { error: 'bad-date' };
39
+ const end = Date.parse(`${expires}T23:59:59Z`);
40
+ if (Number.isNaN(end) || end <= now)
41
+ return { error: 'bad-date' };
42
+ expiresAt = new Date(end).toISOString();
43
+ }
44
+ return { list, range, reason: oneLine(form.get('reason'), LIMITS.reason), expiresAt };
45
+ }
46
+ /** Selected entry ids (checkboxes named `ids`, or one row's button value), at most 500. */
47
+ export function selectedIds(form) {
48
+ return [
49
+ ...new Set(form
50
+ .getAll('ids')
51
+ .filter((v) => typeof v === 'string' && /^ig_[a-z0-9]{16}$/.test(v))
52
+ .slice(0, 500)),
53
+ ];
54
+ }
55
+ /**
56
+ * The settings form → new settings, or an error. The key: a new one replaces the saved one; empty keeps
57
+ * it; "clear" removes it.
58
+ */
59
+ export function parseSettingsForm(form, current) {
60
+ const num = (name) => {
61
+ const raw = form.get(name);
62
+ return typeof raw === 'string' && /^\d{1,6}$/.test(raw) ? Number(raw) : Number.NaN;
63
+ };
64
+ const maxAttempts = num('maxAttempts');
65
+ const windowSeconds = num('windowSeconds');
66
+ const minScore = num('minScore');
67
+ const blockMessage = oneLine(form.get('blockMessage'), LIMITS.blockMessage);
68
+ if (!(maxAttempts >= 1 && maxAttempts <= 1000) ||
69
+ !WINDOWS.includes(windowSeconds) ||
70
+ !(minScore >= 0 && minScore <= 100) ||
71
+ !blockMessage) {
72
+ return { error: 'bad-settings' };
73
+ }
74
+ const typedKey = String(form.get('apiKey') ?? '').trim();
75
+ if (typedKey && !/^[A-Za-z0-9]{1,200}$/.test(typedKey))
76
+ return { error: 'bad-key' };
77
+ const apiKey = typedKey || (form.get('clearKey') === 'on' ? '' : current.apiKey);
78
+ return parseSettings({
79
+ enabled: form.get('enabled') === 'on',
80
+ maxAttempts,
81
+ windowSeconds,
82
+ minScore,
83
+ blockMessage,
84
+ apiKey,
85
+ });
86
+ }
87
+ //# sourceMappingURL=admin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.js","sourceRoot":"","sources":["../src/admin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The dashboard's form actions as data (ADR 0001 §7): parsing what an admin submitted, and the error\n * codes the dashboard explains. Pure; the endpoint (runtime/admin-endpoint.ts) applies the results.\n */\nimport { type Cidr, parseCidr } from './ip.js';\nimport { LIMITS, type ListName, oneLine, parseSettings, type Settings, WINDOWS } from './model.js';\n\nexport const ERRORS = {\n\t'bad-range': 'That isn’t a valid address or range (e.g. 203.0.113.7, 203.0.113.0/24 or 2001:db8::/48).',\n\t'too-broad': 'That range is too broad (IPv4 at least /8, IPv6 at least /16).',\n\t'bad-date': 'The expiry date must be a valid date in the future.',\n\texists: 'That address or range is already on this list.',\n\t'own-address': 'That includes your own current address: you would lock yourself out.',\n\twhitelisted: 'That includes a whitelisted address. Remove it from the whitelist first.',\n\t'nothing-selected': 'Nothing was selected.',\n\t'bad-settings': 'Some settings weren’t valid; nothing was saved.',\n\t'bad-key': 'An AbuseIPDB key has only letters and digits.',\n\tunknown: 'Unknown action.',\n\tfailed: 'That didn’t work. Please try again.',\n} as const;\nexport type AdminError = keyof typeof ERRORS;\n\n/** Ranges broader than this aren't accepted (no banning half the internet by a typo). */\nconst MIN_PREFIX = { 4: 8, 6: 16 } as const;\n\nexport interface AddRequest {\n\tlist: ListName;\n\trange: Cidr;\n\treason: string;\n\t/** Blacklist only: ISO time the block ends (end of that day, UTC), or null. */\n\texpiresAt: string | null;\n}\n\nconst LIST_NAMES: readonly ListName[] = ['whitelist', 'flagged', 'blacklist', 'banned'];\nconst isList = (v: unknown): v is ListName => LIST_NAMES.includes(v as ListName);\n\n/** \"Add an address\" → a request, or why not. Flagged entries are only made automatically. */\nexport function parseAdd(form: FormData, now = Date.now()): AddRequest | { error: AdminError } {\n\tconst list = form.get('list');\n\tif (!isList(list) || list === 'flagged') return { error: 'unknown' };\n\tconst range = parseCidr(form.get('range'));\n\tif (!range) return { error: 'bad-range' };\n\tif (range.prefix < MIN_PREFIX[range.version]) return { error: 'too-broad' };\n\tlet expiresAt: string | null = null;\n\tconst expires = form.get('expires');\n\tif (list === 'blacklist' && typeof expires === 'string' && expires !== '') {\n\t\tif (!/^\\d{4}-\\d{2}-\\d{2}$/.test(expires)) return { error: 'bad-date' };\n\t\tconst end = Date.parse(`${expires}T23:59:59Z`);\n\t\tif (Number.isNaN(end) || end <= now) return { error: 'bad-date' };\n\t\texpiresAt = new Date(end).toISOString();\n\t}\n\treturn { list, range, reason: oneLine(form.get('reason'), LIMITS.reason), expiresAt };\n}\n\n/** Selected entry ids (checkboxes named `ids`, or one row's button value), at most 500. */\nexport function selectedIds(form: FormData): string[] {\n\treturn [\n\t\t...new Set(\n\t\t\tform\n\t\t\t\t.getAll('ids')\n\t\t\t\t.filter((v): v is string => typeof v === 'string' && /^ig_[a-z0-9]{16}$/.test(v))\n\t\t\t\t.slice(0, 500),\n\t\t),\n\t];\n}\n\n/**\n * The settings form → new settings, or an error. The key: a new one replaces the saved one; empty keeps\n * it; \"clear\" removes it.\n */\nexport function parseSettingsForm(form: FormData, current: Settings): Settings | { error: AdminError } {\n\tconst num = (name: string) => {\n\t\tconst raw = form.get(name);\n\t\treturn typeof raw === 'string' && /^\\d{1,6}$/.test(raw) ? Number(raw) : Number.NaN;\n\t};\n\tconst maxAttempts = num('maxAttempts');\n\tconst windowSeconds = num('windowSeconds');\n\tconst minScore = num('minScore');\n\tconst blockMessage = oneLine(form.get('blockMessage'), LIMITS.blockMessage);\n\tif (\n\t\t!(maxAttempts >= 1 && maxAttempts <= 1000) ||\n\t\t!WINDOWS.includes(windowSeconds as (typeof WINDOWS)[number]) ||\n\t\t!(minScore >= 0 && minScore <= 100) ||\n\t\t!blockMessage\n\t) {\n\t\treturn { error: 'bad-settings' };\n\t}\n\tconst typedKey = String(form.get('apiKey') ?? '').trim();\n\tif (typedKey && !/^[A-Za-z0-9]{1,200}$/.test(typedKey)) return { error: 'bad-key' };\n\tconst apiKey = typedKey || (form.get('clearKey') === 'on' ? '' : current.apiKey);\n\treturn parseSettings({\n\t\tenabled: form.get('enabled') === 'on',\n\t\tmaxAttempts,\n\t\twindowSeconds,\n\t\tminScore,\n\t\tblockMessage,\n\t\tapiKey,\n\t});\n}\n"]}
@@ -0,0 +1,33 @@
1
+ import { definePlugin } from 'studiocms/plugins';
2
+ export type { CheckResult as AbuseIpDbResult } from './abuseipdb.js';
3
+ export type { Cidr, Ip } from './ip.js';
4
+ export { cidrContains, formatCidr, formatIp, isPublic, parseCidr, parseIp, resolveClientIp, subjectOf } from './ip.js';
5
+ export type { Decision, Entry, ListName, Settings } from './model.js';
6
+ export { DEFAULT_SETTINGS, decide, parseEntry, parseSettings } from './model.js';
7
+ /** StudioCMS names a plugin page's address from its identifier (`/` and `-` become `_`). */
8
+ export declare const DASHBOARD_PAGE = "nascencestudio_ipguard/ipguard";
9
+ export declare const ADMIN_ROUTE = "/_ipguard/admin";
10
+ export interface IpGuardOptions {
11
+ /**
12
+ * Proxies whose `X-Forwarded-For` is believed, as addresses or CIDR ranges. Default: loopback and
13
+ * private networks, where a reverse proxy (e.g. Caddy in Docker) runs. Requests from anywhere else
14
+ * use their connecting address, whatever headers they send.
15
+ */
16
+ trustedProxies?: string[];
17
+ /** The AbuseIPDB endpoint (only for tests with a local fake; https unless on localhost). */
18
+ abuseIpDbEndpoint?: string;
19
+ }
20
+ export type IpGuardPlugin = ReturnType<typeof definePlugin>;
21
+ /**
22
+ * The IP Guard plugin.
23
+ *
24
+ * @example
25
+ * ```js
26
+ * // studiocms.config.mjs
27
+ * import ipguard from '@nascencestudio/ipguard';
28
+ * export default defineStudioCMSConfig({ plugins: [ipguard()] });
29
+ * ```
30
+ */
31
+ export declare function ipguard(options?: IpGuardOptions): IpGuardPlugin;
32
+ export default ipguard;
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAIjD,YAAY,EAAE,WAAW,IAAI,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACrE,YAAY,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACvH,YAAY,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAIjF,4FAA4F;AAC5F,eAAO,MAAM,cAAc,mCAAmC,CAAC;AAC/D,eAAO,MAAM,WAAW,oBAAoB,CAAC;AAE7C,MAAM,WAAW,cAAc;IAC9B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,4FAA4F;IAC5F,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AAkB5D;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,cAAmB,GAAG,aAAa,CAuDnE;AAED,eAAe,OAAO,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @nascencestudio/ipguard: IP protection for StudioCMS sites (ADR 0001).
3
+ * - Review lists: whitelist, flagged (blocked until reviewed), blacklist (with expiry), banned (every
4
+ * request refused); single addresses or ranges.
5
+ * - Rate limiting and AbuseIPDB reputation checks for protected actions, through
6
+ * `@nascencestudio/ipguard/guard` (webforms uses it).
7
+ * - Dashboard → IP Guard (admins): the lists, settings, and a check of what happens to an address.
8
+ * `IPGUARD_DISABLE=1` turns all blocking off (the way back in after a lockout).
9
+ */
10
+ import { fileURLToPath } from 'node:url';
11
+ import { definePlugin } from 'studiocms/plugins';
12
+ import { ENDPOINT } from './abuseipdb.js';
13
+ import { DEFAULT_TRUSTED_PROXIES, parseCidr } from './ip.js';
14
+ export { cidrContains, formatCidr, formatIp, isPublic, parseCidr, parseIp, resolveClientIp, subjectOf } from './ip.js';
15
+ export { DEFAULT_SETTINGS, decide, parseEntry, parseSettings } from './model.js';
16
+ const PACKAGE_NAME = '@nascencestudio/ipguard';
17
+ const CONFIG_MODULE = 'virtual:ipguard/config';
18
+ /** StudioCMS names a plugin page's address from its identifier (`/` and `-` become `_`). */
19
+ export const DASHBOARD_PAGE = 'nascencestudio_ipguard/ipguard';
20
+ export const ADMIN_ROUTE = '/_ipguard/admin';
21
+ const resolve = (path) => fileURLToPath(new URL(path, import.meta.url));
22
+ function checkOptions(options) {
23
+ const trustedProxies = options.trustedProxies ?? DEFAULT_TRUSTED_PROXIES;
24
+ for (const range of trustedProxies) {
25
+ if (!parseCidr(range))
26
+ throw new Error(`[ipguard] trustedProxies: "${range}" isn't an address or CIDR range.`);
27
+ }
28
+ const endpoint = options.abuseIpDbEndpoint ?? ENDPOINT;
29
+ const url = URL.canParse(endpoint) ? new URL(endpoint) : null;
30
+ const local = url && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
31
+ if (!url || !(url.protocol === 'https:' || (url.protocol === 'http:' && local))) {
32
+ throw new Error('[ipguard] abuseIpDbEndpoint must be an https address (http only on localhost).');
33
+ }
34
+ return { trustedProxies, abuseIpDbEndpoint: endpoint, dashboardPage: DASHBOARD_PAGE };
35
+ }
36
+ /**
37
+ * The IP Guard plugin.
38
+ *
39
+ * @example
40
+ * ```js
41
+ * // studiocms.config.mjs
42
+ * import ipguard from '@nascencestudio/ipguard';
43
+ * export default defineStudioCMSConfig({ plugins: [ipguard()] });
44
+ * ```
45
+ */
46
+ export function ipguard(options = {}) {
47
+ const runtimeConfig = checkOptions(options);
48
+ return definePlugin({
49
+ identifier: PACKAGE_NAME,
50
+ name: 'IP Guard',
51
+ hooks: {
52
+ 'studiocms:astro-config': ({ addIntegrations }) => {
53
+ addIntegrations({
54
+ name: PACKAGE_NAME,
55
+ hooks: {
56
+ 'astro:config:setup': ({ injectRoute, addMiddleware, updateConfig }) => {
57
+ injectRoute({
58
+ pattern: ADMIN_ROUTE,
59
+ entrypoint: resolve('./runtime/admin-endpoint.js'),
60
+ prerender: false,
61
+ });
62
+ // Bans are checked before everything else (ADR 0001 §2).
63
+ addMiddleware({ entrypoint: resolve('./runtime/middleware.js'), order: 'pre' });
64
+ updateConfig({
65
+ vite: {
66
+ plugins: [
67
+ {
68
+ name: 'ipguard:config',
69
+ resolveId: (id) => (id === CONFIG_MODULE ? `\0${CONFIG_MODULE}` : undefined),
70
+ load: (id) => id === `\0${CONFIG_MODULE}` ? `export default ${JSON.stringify(runtimeConfig)};` : undefined,
71
+ },
72
+ ],
73
+ },
74
+ });
75
+ },
76
+ },
77
+ });
78
+ },
79
+ 'studiocms:dashboard': ({ setDashboard }) => {
80
+ setDashboard({
81
+ translations: { en: {} },
82
+ dashboardPages: {
83
+ admin: [
84
+ {
85
+ title: { en: 'IP Guard' },
86
+ description: 'Blocked, flagged and trusted addresses; spam protection settings.',
87
+ route: 'ipguard',
88
+ icon: 'heroicons:shield-check',
89
+ sidebar: 'double',
90
+ requiredPermissions: 'admin',
91
+ innerSidebarComponent: resolve('./runtime/IpGuardNav.astro'),
92
+ pageBodyComponent: resolve('./runtime/IpGuardPage.astro'),
93
+ },
94
+ ],
95
+ },
96
+ });
97
+ },
98
+ },
99
+ });
100
+ }
101
+ export default ipguard;
102
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAI7D,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEvH,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEjF,MAAM,YAAY,GAAG,yBAAyB,CAAC;AAC/C,MAAM,aAAa,GAAG,wBAAwB,CAAC;AAC/C,4FAA4F;AAC5F,MAAM,CAAC,MAAM,cAAc,GAAG,gCAAgC,CAAC;AAC/D,MAAM,CAAC,MAAM,WAAW,GAAG,iBAAiB,CAAC;AAe7C,MAAM,OAAO,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,aAAa,CAAC,IAAI,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAEhF,SAAS,YAAY,CAAC,OAAuB;IAC5C,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,uBAAuB,CAAC;IACzE,KAAK,MAAM,KAAK,IAAI,cAAc,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,KAAK,mCAAmC,CAAC,CAAC;IAChH,CAAC;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,iBAAiB,IAAI,QAAQ,CAAC;IACvD,MAAM,GAAG,GAAG,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC9D,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAChF,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,CAAC,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,KAAK,CAAC,CAAC,EAAE,CAAC;QACjF,MAAM,IAAI,KAAK,CAAC,gFAAgF,CAAC,CAAC;IACnG,CAAC;IACD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,QAAQ,EAAE,aAAa,EAAE,cAAc,EAAE,CAAC;AACvF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,OAAO,CAAC,UAA0B,EAAE;IACnD,MAAM,aAAa,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,YAAY,CAAC;QACnB,UAAU,EAAE,YAAY;QACxB,IAAI,EAAE,UAAU;QAChB,KAAK,EAAE;YACN,wBAAwB,EAAE,CAAC,EAAE,eAAe,EAAE,EAAE,EAAE;gBACjD,eAAe,CAAC;oBACf,IAAI,EAAE,YAAY;oBAClB,KAAK,EAAE;wBACN,oBAAoB,EAAE,CAAC,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,EAAE,EAAE;4BACtE,WAAW,CAAC;gCACX,OAAO,EAAE,WAAW;gCACpB,UAAU,EAAE,OAAO,CAAC,6BAA6B,CAAC;gCAClD,SAAS,EAAE,KAAK;6BAChB,CAAC,CAAC;4BACH,yDAAyD;4BACzD,aAAa,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,yBAAyB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;4BAChF,YAAY,CAAC;gCACZ,IAAI,EAAE;oCACL,OAAO,EAAE;wCACR;4CACC,IAAI,EAAE,gBAAgB;4CACtB,SAAS,EAAE,CAAC,EAAU,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,aAAa,CAAC,CAAC,CAAC,KAAK,aAAa,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;4CACpF,IAAI,EAAE,CAAC,EAAU,EAAE,EAAE,CACpB,EAAE,KAAK,KAAK,aAAa,EAAE,CAAC,CAAC,CAAC,kBAAkB,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS;yCAC7F;qCACD;iCACD;6BACD,CAAC,CAAC;wBACJ,CAAC;qBACD;iBACD,CAAC,CAAC;YACJ,CAAC;YACD,qBAAqB,EAAE,CAAC,EAAE,YAAY,EAAE,EAAE,EAAE;gBAC3C,YAAY,CAAC;oBACZ,YAAY,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE;oBACxB,cAAc,EAAE;wBACf,KAAK,EAAE;4BACN;gCACC,KAAK,EAAE,EAAE,EAAE,EAAE,UAAU,EAAE;gCACzB,WAAW,EAAE,mEAAmE;gCAChF,KAAK,EAAE,SAAS;gCAChB,IAAI,EAAE,wBAAwB;gCAC9B,OAAO,EAAE,QAAQ;gCACjB,mBAAmB,EAAE,OAAO;gCAC5B,qBAAqB,EAAE,OAAO,CAAC,4BAA4B,CAAC;gCAC5D,iBAAiB,EAAE,OAAO,CAAC,6BAA6B,CAAC;6BACzD;yBACD;qBACD;iBACD,CAAC,CAAC;YACJ,CAAC;SACD;KACD,CAAC,CAAC;AACJ,CAAC;AAED,eAAe,OAAO,CAAC","sourcesContent":["/**\n * @nascencestudio/ipguard: IP protection for StudioCMS sites (ADR 0001).\n * - Review lists: whitelist, flagged (blocked until reviewed), blacklist (with expiry), banned (every\n * request refused); single addresses or ranges.\n * - Rate limiting and AbuseIPDB reputation checks for protected actions, through\n * `@nascencestudio/ipguard/guard` (webforms uses it).\n * - Dashboard → IP Guard (admins): the lists, settings, and a check of what happens to an address.\n * `IPGUARD_DISABLE=1` turns all blocking off (the way back in after a lockout).\n */\nimport { fileURLToPath } from 'node:url';\nimport { definePlugin } from 'studiocms/plugins';\nimport { ENDPOINT } from './abuseipdb.js';\nimport { DEFAULT_TRUSTED_PROXIES, parseCidr } from './ip.js';\n\nexport type { CheckResult as AbuseIpDbResult } from './abuseipdb.js';\nexport type { Cidr, Ip } from './ip.js';\nexport { cidrContains, formatCidr, formatIp, isPublic, parseCidr, parseIp, resolveClientIp, subjectOf } from './ip.js';\nexport type { Decision, Entry, ListName, Settings } from './model.js';\nexport { DEFAULT_SETTINGS, decide, parseEntry, parseSettings } from './model.js';\n\nconst PACKAGE_NAME = '@nascencestudio/ipguard';\nconst CONFIG_MODULE = 'virtual:ipguard/config';\n/** StudioCMS names a plugin page's address from its identifier (`/` and `-` become `_`). */\nexport const DASHBOARD_PAGE = 'nascencestudio_ipguard/ipguard';\nexport const ADMIN_ROUTE = '/_ipguard/admin';\n\nexport interface IpGuardOptions {\n\t/**\n\t * Proxies whose `X-Forwarded-For` is believed, as addresses or CIDR ranges. Default: loopback and\n\t * private networks, where a reverse proxy (e.g. Caddy in Docker) runs. Requests from anywhere else\n\t * use their connecting address, whatever headers they send.\n\t */\n\ttrustedProxies?: string[];\n\t/** The AbuseIPDB endpoint (only for tests with a local fake; https unless on localhost). */\n\tabuseIpDbEndpoint?: string;\n}\n\nexport type IpGuardPlugin = ReturnType<typeof definePlugin>;\n\nconst resolve = (path: string) => fileURLToPath(new URL(path, import.meta.url));\n\nfunction checkOptions(options: IpGuardOptions) {\n\tconst trustedProxies = options.trustedProxies ?? DEFAULT_TRUSTED_PROXIES;\n\tfor (const range of trustedProxies) {\n\t\tif (!parseCidr(range)) throw new Error(`[ipguard] trustedProxies: \"${range}\" isn't an address or CIDR range.`);\n\t}\n\tconst endpoint = options.abuseIpDbEndpoint ?? ENDPOINT;\n\tconst url = URL.canParse(endpoint) ? new URL(endpoint) : null;\n\tconst local = url && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);\n\tif (!url || !(url.protocol === 'https:' || (url.protocol === 'http:' && local))) {\n\t\tthrow new Error('[ipguard] abuseIpDbEndpoint must be an https address (http only on localhost).');\n\t}\n\treturn { trustedProxies, abuseIpDbEndpoint: endpoint, dashboardPage: DASHBOARD_PAGE };\n}\n\n/**\n * The IP Guard plugin.\n *\n * @example\n * ```js\n * // studiocms.config.mjs\n * import ipguard from '@nascencestudio/ipguard';\n * export default defineStudioCMSConfig({ plugins: [ipguard()] });\n * ```\n */\nexport function ipguard(options: IpGuardOptions = {}): IpGuardPlugin {\n\tconst runtimeConfig = checkOptions(options);\n\treturn definePlugin({\n\t\tidentifier: PACKAGE_NAME,\n\t\tname: 'IP Guard',\n\t\thooks: {\n\t\t\t'studiocms:astro-config': ({ addIntegrations }) => {\n\t\t\t\taddIntegrations({\n\t\t\t\t\tname: PACKAGE_NAME,\n\t\t\t\t\thooks: {\n\t\t\t\t\t\t'astro:config:setup': ({ injectRoute, addMiddleware, updateConfig }) => {\n\t\t\t\t\t\t\tinjectRoute({\n\t\t\t\t\t\t\t\tpattern: ADMIN_ROUTE,\n\t\t\t\t\t\t\t\tentrypoint: resolve('./runtime/admin-endpoint.js'),\n\t\t\t\t\t\t\t\tprerender: false,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t// Bans are checked before everything else (ADR 0001 §2).\n\t\t\t\t\t\t\taddMiddleware({ entrypoint: resolve('./runtime/middleware.js'), order: 'pre' });\n\t\t\t\t\t\t\tupdateConfig({\n\t\t\t\t\t\t\t\tvite: {\n\t\t\t\t\t\t\t\t\tplugins: [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tname: 'ipguard:config',\n\t\t\t\t\t\t\t\t\t\t\tresolveId: (id: string) => (id === CONFIG_MODULE ? `\\0${CONFIG_MODULE}` : undefined),\n\t\t\t\t\t\t\t\t\t\t\tload: (id: string) =>\n\t\t\t\t\t\t\t\t\t\t\t\tid === `\\0${CONFIG_MODULE}` ? `export default ${JSON.stringify(runtimeConfig)};` : undefined,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t\t'studiocms:dashboard': ({ setDashboard }) => {\n\t\t\t\tsetDashboard({\n\t\t\t\t\ttranslations: { en: {} },\n\t\t\t\t\tdashboardPages: {\n\t\t\t\t\t\tadmin: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttitle: { en: 'IP Guard' },\n\t\t\t\t\t\t\t\tdescription: 'Blocked, flagged and trusted addresses; spam protection settings.',\n\t\t\t\t\t\t\t\troute: 'ipguard',\n\t\t\t\t\t\t\t\ticon: 'heroicons:shield-check',\n\t\t\t\t\t\t\t\tsidebar: 'double',\n\t\t\t\t\t\t\t\trequiredPermissions: 'admin',\n\t\t\t\t\t\t\t\tinnerSidebarComponent: resolve('./runtime/IpGuardNav.astro'),\n\t\t\t\t\t\t\t\tpageBodyComponent: resolve('./runtime/IpGuardPage.astro'),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t},\n\t});\n}\n\nexport default ipguard;\n"]}
package/dist/ip.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * IP addresses and ranges (ADR 0001): strict parsing of IPv4 and IPv6 (no zone ids, no leading zeros in
3
+ * IPv4 octets, which some parsers read as octal), canonical formatting (RFC 5952 for IPv6), CIDR ranges,
4
+ * the "subject" an automatic action applies to (an IPv4 address, or an IPv6 /64, since one IPv6 visitor
5
+ * can rotate through a whole /64), whether an address is public, and the visitor's real address behind
6
+ * trusted proxies. Pure.
7
+ */
8
+ export type IpVersion = 4 | 6;
9
+ export interface Ip {
10
+ version: IpVersion;
11
+ /** The address as a number (32 or 128 bits). */
12
+ value: bigint;
13
+ }
14
+ export interface Cidr {
15
+ version: IpVersion;
16
+ /** The network address (host bits cleared). */
17
+ network: bigint;
18
+ prefix: number;
19
+ }
20
+ /** An address, or null when it isn't a valid one. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is read as IPv4. */
21
+ export declare function parseIp(input: unknown): Ip | null;
22
+ /** The canonical text of an address (IPv6 per RFC 5952: lowercase, zeros compressed once). */
23
+ export declare function formatIp(ip: Ip): string;
24
+ /**
25
+ * A range from `a.b.c.d/n`, `x::/n`, or a single address (a /32 or /128). Host bits are cleared
26
+ * (`10.1.2.3/8` is `10.0.0.0/8`). Null when invalid.
27
+ */
28
+ export declare function parseCidr(input: unknown): Cidr | null;
29
+ export declare function formatCidr(cidr: Cidr): string;
30
+ export declare function cidrContains(cidr: Cidr, ip: Ip): boolean;
31
+ /** How many addresses a range covers, as a readable size (`1`, `256`, `/64`). */
32
+ export declare const isSingleAddress: (cidr: Cidr) => boolean;
33
+ /**
34
+ * What automatic actions (rate limit, flag, blacklist, ban) apply to: the IPv4 address itself, or the
35
+ * IPv6 address's /64.
36
+ */
37
+ export declare function subjectOf(ip: Ip): Cidr;
38
+ /** Whether an address is on the public internet (AbuseIPDB only knows about those). */
39
+ export declare function isPublic(ip: Ip): boolean;
40
+ /** Ranges trusted as proxies by default: loopback and private networks (where a reverse proxy runs). */
41
+ export declare const DEFAULT_TRUSTED_PROXIES: string[];
42
+ /**
43
+ * Whether an address is one of the trusted proxies. Such an address is never judged: a request that
44
+ * reaches us without a forwarded address looks like it comes from the proxy itself (or from loopback in
45
+ * development), and flagging or banning that would block every visitor at once.
46
+ */
47
+ export declare const isTrustedProxy: (ip: Ip, trusted: readonly Cidr[]) => boolean;
48
+ /**
49
+ * The visitor's address: the connecting address, unless it's a trusted proxy; then the rightmost
50
+ * address in `X-Forwarded-For` that isn't a trusted proxy (addresses further left can be forged by the
51
+ * visitor). Null when nothing valid is found.
52
+ */
53
+ export declare function resolveClientIp(remote: string | null | undefined, forwardedFor: string | null | undefined, trusted: readonly Cidr[]): Ip | null;
54
+ //# sourceMappingURL=ip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip.d.ts","sourceRoot":"","sources":["../src/ip.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,CAAC,CAAC;AAE9B,MAAM,WAAW,EAAE;IAClB,OAAO,EAAE,SAAS,CAAC;IACnB,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,IAAI;IACpB,OAAO,EAAE,SAAS,CAAC;IACnB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CACf;AAgDD,0GAA0G;AAC1G,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,EAAE,GAAG,IAAI,CAYjD;AAED,8FAA8F;AAC9F,wBAAgB,QAAQ,CAAC,EAAE,EAAE,EAAE,GAAG,MAAM,CA0BvC;AAOD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI,CAgBrD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAG7C;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAExD;AAED,iFAAiF;AACjF,eAAO,MAAM,eAAe,GAAI,MAAM,IAAI,YAAuC,CAAC;AAElF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,EAAE,GAAG,IAAI,CAGtC;AAwBD,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,EAAE,GAAG,OAAO,CAGxC;AAED,wGAAwG;AACxG,eAAO,MAAM,uBAAuB,UAOnC,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,IAAI,EAAE,EAAE,SAAS,SAAS,IAAI,EAAE,YAAqD,CAAC;AAErH;;;;GAIG;AACH,wBAAgB,eAAe,CAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACjC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACvC,OAAO,EAAE,SAAS,IAAI,EAAE,GACtB,EAAE,GAAG,IAAI,CAcX"}