@ingram-tech/bot-protection 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.
- package/README.md +79 -0
- package/dist/botid.d.ts +16 -0
- package/dist/botid.d.ts.map +1 -0
- package/dist/botid.js +24 -0
- package/dist/botid.js.map +1 -0
- package/dist/fields.d.ts +9 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +9 -0
- package/dist/fields.js.map +1 -0
- package/dist/honeypot.d.ts +4 -0
- package/dist/honeypot.d.ts.map +1 -0
- package/dist/honeypot.js +22 -0
- package/dist/honeypot.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +11 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +17 -0
- package/dist/keys.js.map +1 -0
- package/dist/token.d.ts +15 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +38 -0
- package/dist/token.js.map +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +34 -0
- package/dist/verify.js.map +1 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @ingram-tech/bot-protection
|
|
2
|
+
|
|
3
|
+
Invisible, layered bot protection for forms — no CAPTCHA. Three layers, cheapest
|
|
4
|
+
first:
|
|
5
|
+
|
|
6
|
+
1. **Honeypot** — a hidden field real users never fill; bots do. Zero-dep.
|
|
7
|
+
2. **Signed timing token** — an HMAC-signed timestamp; rejects submissions that
|
|
8
|
+
arrive implausibly fast or stale. Zero-dep (`node:crypto`).
|
|
9
|
+
3. **Vercel BotID** — optional, invisible server check. Degrades to a no-op when
|
|
10
|
+
`botid` isn't installed or you're off Vercel.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @ingram-tech/bot-protection
|
|
16
|
+
# optional, for layer 3:
|
|
17
|
+
bun add botid
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Set `BOT_PROTECTION_SECRET` (e.g. `openssl rand -hex 32`).
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
**Render the form** (token is minted server-side):
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { createFormToken } from "@ingram-tech/bot-protection";
|
|
28
|
+
import { HoneypotField } from "@ingram-tech/bot-protection/honeypot";
|
|
29
|
+
|
|
30
|
+
export default function ContactPage() {
|
|
31
|
+
const token = createFormToken(); // server component / server-side
|
|
32
|
+
return (
|
|
33
|
+
<form action="/api/contact" method="post">
|
|
34
|
+
{/* ...your real fields... */}
|
|
35
|
+
<HoneypotField token={token} />
|
|
36
|
+
<button type="submit">Send</button>
|
|
37
|
+
</form>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Verify on submit** (API route or server action):
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { verifyHuman } from "@ingram-tech/bot-protection";
|
|
46
|
+
|
|
47
|
+
export async function POST(request: Request) {
|
|
48
|
+
const formData = await request.formData();
|
|
49
|
+
const result = await verifyHuman({ formData });
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
// Silently drop — respond 200 without acting, so bots aren't told why.
|
|
52
|
+
return Response.json({ ok: true });
|
|
53
|
+
}
|
|
54
|
+
// ...send the email / save the lead...
|
|
55
|
+
return Response.json({ ok: true });
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`verifyHuman` also accepts a plain object (`{ formData: { ...fields } }`) and a
|
|
60
|
+
`timing` window (`{ minMs, maxMs }`). Pass `botid: false` to skip layer 3.
|
|
61
|
+
|
|
62
|
+
## Vercel BotID wiring (per app, layer 3 only)
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// next.config.ts
|
|
66
|
+
import { withBotId } from "botid/next/config";
|
|
67
|
+
export default withBotId({ /* your config */ });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// instrumentation-client.ts
|
|
72
|
+
import { initBotId } from "botid/client/core";
|
|
73
|
+
initBotId({ protect: [{ path: "/api/contact", method: "POST" }] });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Why "silently drop"?
|
|
77
|
+
|
|
78
|
+
Returning success without acting (rather than a 4xx) avoids teaching spam tools
|
|
79
|
+
which layer caught them — which is what makes them give up.
|
package/dist/botid.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around Vercel BotID's server check.
|
|
3
|
+
*
|
|
4
|
+
* `botid` is an optional peer dependency, and BotID only does real work on
|
|
5
|
+
* Vercel. We import it dynamically via a non-literal specifier so:
|
|
6
|
+
* - the package builds without `botid` installed, and
|
|
7
|
+
* - if it isn't installed (or the call throws off-Vercel/in dev), we degrade
|
|
8
|
+
* to "not a bot" rather than blocking real users.
|
|
9
|
+
*
|
|
10
|
+
* App-side wiring (per site, not in this package): add `withBotId(nextConfig)`
|
|
11
|
+
* in next.config and `initBotId({ protect: [...] })` in instrumentation-client.
|
|
12
|
+
*/
|
|
13
|
+
export declare const checkBot: () => Promise<{
|
|
14
|
+
isBot: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
//# sourceMappingURL=botid.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"botid.d.ts","sourceRoot":"","sources":["../src/botid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,QAAQ,QAAa,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,CAW3D,CAAC"}
|
package/dist/botid.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around Vercel BotID's server check.
|
|
3
|
+
*
|
|
4
|
+
* `botid` is an optional peer dependency, and BotID only does real work on
|
|
5
|
+
* Vercel. We import it dynamically via a non-literal specifier so:
|
|
6
|
+
* - the package builds without `botid` installed, and
|
|
7
|
+
* - if it isn't installed (or the call throws off-Vercel/in dev), we degrade
|
|
8
|
+
* to "not a bot" rather than blocking real users.
|
|
9
|
+
*
|
|
10
|
+
* App-side wiring (per site, not in this package): add `withBotId(nextConfig)`
|
|
11
|
+
* in next.config and `initBotId({ protect: [...] })` in instrumentation-client.
|
|
12
|
+
*/
|
|
13
|
+
export const checkBot = async () => {
|
|
14
|
+
try {
|
|
15
|
+
// Non-literal specifier: keeps tsc from requiring `botid` at build time.
|
|
16
|
+
const specifier = "botid/server";
|
|
17
|
+
const mod = (await import(specifier));
|
|
18
|
+
return await mod.checkBotId();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { isBot: false };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=botid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"botid.js","sourceRoot":"","sources":["../src/botid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,IAAiC,EAAE;IAC/D,IAAI,CAAC;QACJ,yEAAyE;QACzE,MAAM,SAAS,GAAG,cAAc,CAAC;QACjC,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,CAEnC,CAAC;QACF,OAAO,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACzB,CAAC;AACF,CAAC,CAAC"}
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared field names used by the honeypot component and the server verifier.
|
|
3
|
+
* Kept in one place so the client and server never drift.
|
|
4
|
+
*/
|
|
5
|
+
/** Hidden field real users never fill; bots auto-complete it. */
|
|
6
|
+
export declare const HONEYPOT_FIELD = "company_url";
|
|
7
|
+
/** Hidden field carrying the signed timing token. */
|
|
8
|
+
export declare const TOKEN_FIELD = "_bp_token";
|
|
9
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../src/fields.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAE5C,qDAAqD;AACrD,eAAO,MAAM,WAAW,cAAc,CAAC"}
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared field names used by the honeypot component and the server verifier.
|
|
3
|
+
* Kept in one place so the client and server never drift.
|
|
4
|
+
*/
|
|
5
|
+
/** Hidden field real users never fill; bots auto-complete it. */
|
|
6
|
+
export const HONEYPOT_FIELD = "company_url";
|
|
7
|
+
/** Hidden field carrying the signed timing token. */
|
|
8
|
+
export const TOKEN_FIELD = "_bp_token";
|
|
9
|
+
//# sourceMappingURL=fields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.js","sourceRoot":"","sources":["../src/fields.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,iEAAiE;AACjE,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAC;AAE5C,qDAAqD;AACrD,MAAM,CAAC,MAAM,WAAW,GAAG,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"honeypot.d.ts","sourceRoot":"","sources":["../src/honeypot.tsx"],"names":[],"mappings":"AAuBA,eAAO,MAAM,aAAa,GAAI,WAAW;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,4CAczD,CAAC"}
|
package/dist/honeypot.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields";
|
|
3
|
+
/**
|
|
4
|
+
* Visually-hidden honeypot + signed timing token, dropped inside any <form>.
|
|
5
|
+
*
|
|
6
|
+
* Presentational only (no client JS), so it renders fine in server or client
|
|
7
|
+
* components. Pass a `token` minted server-side with `createFormToken()`.
|
|
8
|
+
*
|
|
9
|
+
* Real users never see or fill the honeypot (off-screen, aria-hidden,
|
|
10
|
+
* tabIndex -1, autocomplete off); bots that fill every field give themselves
|
|
11
|
+
* away.
|
|
12
|
+
*/
|
|
13
|
+
const hidden = {
|
|
14
|
+
position: "absolute",
|
|
15
|
+
left: "-9999px",
|
|
16
|
+
top: "-9999px",
|
|
17
|
+
width: "1px",
|
|
18
|
+
height: "1px",
|
|
19
|
+
overflow: "hidden",
|
|
20
|
+
};
|
|
21
|
+
export const HoneypotField = ({ token }) => (_jsxs("div", { "aria-hidden": "true", style: hidden, children: [_jsxs("label", { children: ["Leave this field empty", _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }), _jsx("input", { type: "hidden", name: TOKEN_FIELD, defaultValue: token })] }));
|
|
22
|
+
//# sourceMappingURL=honeypot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"honeypot.js","sourceRoot":"","sources":["../src/honeypot.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEvD;;;;;;;;;GASG;AAEH,MAAM,MAAM,GAAkB;IAC7B,QAAQ,EAAE,UAAU;IACpB,IAAI,EAAE,SAAS;IACf,GAAG,EAAE,SAAS;IACd,KAAK,EAAE,KAAK;IACZ,MAAM,EAAE,KAAK;IACb,QAAQ,EAAE,QAAQ;CAClB,CAAC;AAEF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,EAAE,KAAK,EAAqB,EAAE,EAAE,CAAC,CAC9D,8BAAiB,MAAM,EAAC,KAAK,EAAE,MAAM,aACpC,sDAEC,gBACC,IAAI,EAAC,MAAM,EACX,IAAI,EAAE,cAAc,EACpB,QAAQ,EAAE,CAAC,CAAC,EACZ,YAAY,EAAC,KAAK,EAClB,YAAY,EAAC,EAAE,GACd,IACK,EACR,gBAAO,IAAI,EAAC,QAAQ,EAAC,IAAI,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,GAAI,IAC1D,CACN,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { checkBot } from "./botid";
|
|
2
|
+
export { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields";
|
|
3
|
+
export { isConfigured } from "./keys";
|
|
4
|
+
export { createFormToken, type TokenCheck, type TokenResult, verifyFormToken, } from "./token";
|
|
5
|
+
export { type VerifyOptions, type VerifyResult, verifyHuman } from "./verify";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EACN,eAAe,EACf,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,eAAe,GACf,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Server entry. The honeypot component lives at "@ingram-tech/bot-protection/honeypot"
|
|
2
|
+
// so importing the verifier server-side never pulls in React.
|
|
3
|
+
export { checkBot } from "./botid";
|
|
4
|
+
export { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields";
|
|
5
|
+
export { isConfigured } from "./keys";
|
|
6
|
+
export { createFormToken, verifyFormToken, } from "./token";
|
|
7
|
+
export { verifyHuman } from "./verify";
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uFAAuF;AACvF,8DAA8D;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EACN,eAAe,EAGf,eAAe,GACf,MAAM,SAAS,CAAC;AACjB,OAAO,EAAyC,WAAW,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment contract for @ingram-tech/bot-protection.
|
|
3
|
+
*
|
|
4
|
+
* BOT_PROTECTION_SECRET — secret used to HMAC-sign the timing token so bots
|
|
5
|
+
* can't forge a "this form was rendered long enough ago" claim. Generate with
|
|
6
|
+
* e.g. `openssl rand -hex 32`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const getSecret: () => string;
|
|
9
|
+
/** Whether the signed-timing layer is configured. */
|
|
10
|
+
export declare const isConfigured: () => boolean;
|
|
11
|
+
//# sourceMappingURL=keys.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,SAAS,QAAO,MAQ5B,CAAC;AAEF,qDAAqD;AACrD,eAAO,MAAM,YAAY,QAAO,OAAqD,CAAC"}
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment contract for @ingram-tech/bot-protection.
|
|
3
|
+
*
|
|
4
|
+
* BOT_PROTECTION_SECRET — secret used to HMAC-sign the timing token so bots
|
|
5
|
+
* can't forge a "this form was rendered long enough ago" claim. Generate with
|
|
6
|
+
* e.g. `openssl rand -hex 32`.
|
|
7
|
+
*/
|
|
8
|
+
export const getSecret = () => {
|
|
9
|
+
const secret = process.env.BOT_PROTECTION_SECRET;
|
|
10
|
+
if (!secret) {
|
|
11
|
+
throw new Error("@ingram-tech/bot-protection: BOT_PROTECTION_SECRET is not configured");
|
|
12
|
+
}
|
|
13
|
+
return secret;
|
|
14
|
+
};
|
|
15
|
+
/** Whether the signed-timing layer is configured. */
|
|
16
|
+
export const isConfigured = () => Boolean(process.env.BOT_PROTECTION_SECRET);
|
|
17
|
+
//# sourceMappingURL=keys.js.map
|
package/dist/keys.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,CAAC,MAAM,SAAS,GAAG,GAAW,EAAE;IACrC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;IACjD,IAAI,CAAC,MAAM,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACd,sEAAsE,CACtE,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC,CAAC;AAEF,qDAAqD;AACrD,MAAM,CAAC,MAAM,YAAY,GAAG,GAAY,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC"}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Mint a token. Call server-side when rendering the form. */
|
|
2
|
+
export declare const createFormToken: () => string;
|
|
3
|
+
export interface TokenCheck {
|
|
4
|
+
/** Reject submissions faster than this (default 2000ms — bots are instant). */
|
|
5
|
+
minMs?: number;
|
|
6
|
+
/** Reject tokens older than this (default 1h — stale/replayed forms). */
|
|
7
|
+
maxMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface TokenResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
reason?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Verify a token's signature and that it falls inside the timing window. */
|
|
14
|
+
export declare const verifyFormToken: (token: string | undefined | null, { minMs, maxMs }?: TokenCheck) => TokenResult;
|
|
15
|
+
//# sourceMappingURL=token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAYA,8DAA8D;AAC9D,eAAO,MAAM,eAAe,QAAO,MAGlC,CAAC;AAEF,MAAM,WAAW,UAAU;IAC1B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,GAC3B,OAAO,MAAM,GAAG,SAAS,GAAG,IAAI,EAChC,mBAA0C,UAAe,KACvD,WAqBF,CAAC"}
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { getSecret } from "./keys";
|
|
3
|
+
/**
|
|
4
|
+
* Signed timing token: `<timestamp>.<hmac>`. The timestamp records when the
|
|
5
|
+
* form was rendered; the HMAC (keyed by BOT_PROTECTION_SECRET) prevents a bot
|
|
6
|
+
* from forging an older timestamp to defeat the "submitted too fast" check.
|
|
7
|
+
*/
|
|
8
|
+
const sign = (ts, secret) => createHmac("sha256", secret).update(ts).digest("hex");
|
|
9
|
+
/** Mint a token. Call server-side when rendering the form. */
|
|
10
|
+
export const createFormToken = () => {
|
|
11
|
+
const ts = Date.now().toString();
|
|
12
|
+
return `${ts}.${sign(ts, getSecret())}`;
|
|
13
|
+
};
|
|
14
|
+
/** Verify a token's signature and that it falls inside the timing window. */
|
|
15
|
+
export const verifyFormToken = (token, { minMs = 2000, maxMs = 60 * 60 * 1000 } = {}) => {
|
|
16
|
+
if (!token)
|
|
17
|
+
return { ok: false, reason: "missing-token" };
|
|
18
|
+
const dot = token.indexOf(".");
|
|
19
|
+
if (dot <= 0)
|
|
20
|
+
return { ok: false, reason: "malformed-token" };
|
|
21
|
+
const ts = token.slice(0, dot);
|
|
22
|
+
const sig = token.slice(dot + 1);
|
|
23
|
+
const expected = sign(ts, getSecret());
|
|
24
|
+
const a = Buffer.from(sig);
|
|
25
|
+
const b = Buffer.from(expected);
|
|
26
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
27
|
+
return { ok: false, reason: "bad-signature" };
|
|
28
|
+
}
|
|
29
|
+
const elapsed = Date.now() - Number(ts);
|
|
30
|
+
if (!Number.isFinite(elapsed))
|
|
31
|
+
return { ok: false, reason: "bad-timestamp" };
|
|
32
|
+
if (elapsed < minMs)
|
|
33
|
+
return { ok: false, reason: "too-fast" };
|
|
34
|
+
if (elapsed > maxMs)
|
|
35
|
+
return { ok: false, reason: "expired" };
|
|
36
|
+
return { ok: true };
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAEnC;;;;GAIG;AAEH,MAAM,IAAI,GAAG,CAAC,EAAU,EAAE,MAAc,EAAU,EAAE,CACnD,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAEvD,8DAA8D;AAC9D,MAAM,CAAC,MAAM,eAAe,GAAG,GAAW,EAAE;IAC3C,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC;IACjC,OAAO,GAAG,EAAE,IAAI,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;AACzC,CAAC,CAAC;AAcF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,eAAe,GAAG,CAC9B,KAAgC,EAChC,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,KAAiB,EAAE,EAC3C,EAAE;IAChB,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAE1D,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,GAAG,IAAI,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC9D,MAAM,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;IAEjC,MAAM,QAAQ,GAAG,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;IACvC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;QACrD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAC/C,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,CAAC;IACxC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAC7E,IAAI,OAAO,GAAG,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC9D,IAAI,OAAO,GAAG,KAAK;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAE7D,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACrB,CAAC,CAAC"}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type TokenCheck } from "./token";
|
|
2
|
+
export interface VerifyResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
/** Which layer rejected: "honeypot" | token reason | "botid". */
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface VerifyOptions {
|
|
8
|
+
/** Submitted form data — a FormData or a plain object. */
|
|
9
|
+
formData: FormData | Record<string, unknown>;
|
|
10
|
+
/** Timing window for the signed token. */
|
|
11
|
+
timing?: TokenCheck;
|
|
12
|
+
/** Run the Vercel BotID layer (default true). Set false to skip. */
|
|
13
|
+
botid?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run all configured layers in order (cheapest first):
|
|
17
|
+
* 1. honeypot — the hidden field must be empty
|
|
18
|
+
* 2. signed timing token — valid signature + plausible timing
|
|
19
|
+
* 3. Vercel BotID — optional, invisible
|
|
20
|
+
*
|
|
21
|
+
* Returns `{ ok: false, reason }` on the first failure. Callers should treat a
|
|
22
|
+
* failure as "silently drop" (respond 200 without acting) so bots aren't told
|
|
23
|
+
* what tripped them.
|
|
24
|
+
*/
|
|
25
|
+
export declare const verifyHuman: (options: VerifyOptions) => Promise<VerifyResult>;
|
|
26
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,UAAU,EAAmB,MAAM,SAAS,CAAC;AAE3D,MAAM,WAAW,YAAY;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC7B,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C,0CAA0C;IAC1C,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAC;CAChB;AAUD;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GAAU,SAAS,aAAa,KAAG,OAAO,CAAC,YAAY,CAiB9E,CAAC"}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { checkBot } from "./botid";
|
|
2
|
+
import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields";
|
|
3
|
+
import { verifyFormToken } from "./token";
|
|
4
|
+
const getField = (formData, name) => {
|
|
5
|
+
const value = formData instanceof FormData
|
|
6
|
+
? formData.get(name)
|
|
7
|
+
: formData[name];
|
|
8
|
+
return typeof value === "string" ? value : "";
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Run all configured layers in order (cheapest first):
|
|
12
|
+
* 1. honeypot — the hidden field must be empty
|
|
13
|
+
* 2. signed timing token — valid signature + plausible timing
|
|
14
|
+
* 3. Vercel BotID — optional, invisible
|
|
15
|
+
*
|
|
16
|
+
* Returns `{ ok: false, reason }` on the first failure. Callers should treat a
|
|
17
|
+
* failure as "silently drop" (respond 200 without acting) so bots aren't told
|
|
18
|
+
* what tripped them.
|
|
19
|
+
*/
|
|
20
|
+
export const verifyHuman = async (options) => {
|
|
21
|
+
if (getField(options.formData, HONEYPOT_FIELD).trim() !== "") {
|
|
22
|
+
return { ok: false, reason: "honeypot" };
|
|
23
|
+
}
|
|
24
|
+
const token = verifyFormToken(getField(options.formData, TOKEN_FIELD), options.timing);
|
|
25
|
+
if (!token.ok)
|
|
26
|
+
return token;
|
|
27
|
+
if (options.botid !== false) {
|
|
28
|
+
const { isBot } = await checkBot();
|
|
29
|
+
if (isBot)
|
|
30
|
+
return { ok: false, reason: "botid" };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true };
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=verify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAAmB,eAAe,EAAE,MAAM,SAAS,CAAC;AAiB3D,MAAM,QAAQ,GAAG,CAAC,QAAmC,EAAE,IAAY,EAAU,EAAE;IAC9E,MAAM,KAAK,GACV,QAAQ,YAAY,QAAQ;QAC3B,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC;QACpB,CAAC,CAAE,QAAoC,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAC/C,CAAC,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,OAAsB,EAAyB,EAAE;IAClF,IAAI,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,cAAc,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC9D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC1C,CAAC;IAED,MAAM,KAAK,GAAG,eAAe,CAC5B,QAAQ,CAAC,OAAO,CAAC,QAAQ,EAAE,WAAW,CAAC,EACvC,OAAO,CAAC,MAAM,CACd,CAAC;IACF,IAAI,CAAC,KAAK,CAAC,EAAE;QAAE,OAAO,KAAK,CAAC;IAE5B,IAAI,OAAO,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;QAC7B,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,QAAQ,EAAE,CAAC;QACnC,IAAI,KAAK;YAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAClD,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;AACrB,CAAC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ingram-tech/bot-protection",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Invisible, layered bot protection for forms — honeypot + signed timing token + Vercel BotID.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ingram-technologies/nextkit.git",
|
|
10
|
+
"directory": "packages/bot-protection"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./honeypot": {
|
|
24
|
+
"types": "./dist/honeypot.d.ts",
|
|
25
|
+
"import": "./dist/honeypot.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"type-check": "tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@ingram-tech/typescript-config": "0.1.0",
|
|
35
|
+
"@types/node": "^20.0.0",
|
|
36
|
+
"@types/react": "^19.0.0",
|
|
37
|
+
"react": "^19.0.0",
|
|
38
|
+
"typescript": "^6.0.3",
|
|
39
|
+
"vitest": "^4.1.6"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"botid": ">=1.0.0",
|
|
43
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"botid": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"react": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|