@tryghost/brute-knex 3.0.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/LICENSE +15 -0
- package/README.md +104 -0
- package/index.d.ts +59 -0
- package/index.js +188 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Copyright (c) 2014, llambda <xxgsoftware@gmail.com>
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
14
|
+
|
|
15
|
+
~
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @tryghost/brute-knex
|
|
2
|
+
|
|
3
|
+
Knex-backed store for [express-brute](https://github.com/AdamPflug/express-brute) that persists rate-limit state in SQL databases.
|
|
4
|
+
|
|
5
|
+
[![NPM Version][npm-version-image]][npm-url]
|
|
6
|
+
[![NPM Downloads][npm-downloads-image]][npm-url]
|
|
7
|
+
[![Node.js Version][node-image]][node-url]
|
|
8
|
+
[![NPM][npm-image]][npm-url]
|
|
9
|
+
|
|
10
|
+
## What It Does
|
|
11
|
+
|
|
12
|
+
`@tryghost/brute-knex` lets `express-brute` share brute-force counters through a Knex table instead of keeping them in process memory. It can create its storage table automatically, use a caller-provided Knex instance, or fall back to a local SQLite database when no Knex instance is supplied.
|
|
13
|
+
|
|
14
|
+
The package supports Node.js `>=20.20.0`. CI exercises the store against SQLite, MySQL, and Postgres, with additional MySQL coverage for existing consumers that provide Knex `0.21.6`.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Install the package and the Knex database driver your app uses:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install @tryghost/brute-knex knex mysql2
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use `pg` instead of `mysql2` for Postgres, or `sqlite3` for SQLite.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const ExpressBrute = require('express-brute');
|
|
30
|
+
const Knex = require('knex');
|
|
31
|
+
const BruteKnex = require('@tryghost/brute-knex');
|
|
32
|
+
|
|
33
|
+
const knex = Knex({
|
|
34
|
+
client: 'mysql2',
|
|
35
|
+
connection: {
|
|
36
|
+
host: '127.0.0.1',
|
|
37
|
+
user: 'root',
|
|
38
|
+
password: 'root',
|
|
39
|
+
database: 'brute_knex'
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const store = new BruteKnex({
|
|
44
|
+
knex,
|
|
45
|
+
tablename: 'brute'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const bruteforce = new ExpressBrute(store, {
|
|
49
|
+
freeRetries: 2
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [example.js](example.js) for a complete Express route.
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
- `tablename`: table name for brute-force records. Defaults to `brute`.
|
|
58
|
+
- `knex`: Knex instance to use. If omitted, `brute-knex` creates a SQLite database at `./brute-knex.sqlite`.
|
|
59
|
+
- `createTable`: set to `false` when the table already exists and should not be created automatically.
|
|
60
|
+
|
|
61
|
+
The table stores `key`, `firstRequest`, `lastRequest`, `lifetime`, and `count` columns. Timestamps are stored as UTC millisecond values.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
This repo uses the organisation default Node.js `22` for local development through `.nvmrc`, and pnpm `10.x` through Corepack. Package support still starts at Node.js `20.20.0` for existing consumers.
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
corepack enable
|
|
69
|
+
pnpm install --frozen-lockfile
|
|
70
|
+
pnpm test
|
|
71
|
+
pnpm lint
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Database-specific test commands:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pnpm run test:e2e:sqlite
|
|
78
|
+
pnpm run test:e2e:mysql
|
|
79
|
+
pnpm run test:e2e:postgres
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The MySQL and Postgres commands expect local test databases unless they are running inside the GitHub Actions service containers. The test suite reads standard connection env vars such as `MYSQL_HOST`, `MYSQL_DATABASE`, `POSTGRES_HOST`, and `POSTGRES_DB`.
|
|
83
|
+
|
|
84
|
+
## Releasing
|
|
85
|
+
|
|
86
|
+
Releases are handled by [`@tryghost/pro-ship`](https://www.npmjs.com/package/@tryghost/pro-ship):
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
pnpm ship
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The `ship` script bumps the version, creates the release commit and tag, pushes them, and publishes `@tryghost/brute-knex` to npm.
|
|
93
|
+
|
|
94
|
+
## Copyright & License
|
|
95
|
+
|
|
96
|
+
Copyright (c) 2014, llambda <xxgsoftware@gmail.com>.
|
|
97
|
+
Released under the [ISC license](LICENSE).
|
|
98
|
+
|
|
99
|
+
[npm-version-image]: https://img.shields.io/npm/v/@tryghost/brute-knex.svg
|
|
100
|
+
[npm-downloads-image]: https://img.shields.io/npm/dm/@tryghost/brute-knex.svg
|
|
101
|
+
[npm-image]: https://nodei.co/npm/@tryghost/brute-knex.png?downloads=true&downloadRank=true&stars=true
|
|
102
|
+
[npm-url]: https://npmjs.org/package/@tryghost/brute-knex
|
|
103
|
+
[node-image]: https://img.shields.io/node/v/@tryghost/brute-knex.svg
|
|
104
|
+
[node-url]: https://nodejs.org/download/
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
|
|
3
|
+
declare namespace KnexStore {
|
|
4
|
+
type TimestampInput = Date | number | string;
|
|
5
|
+
|
|
6
|
+
interface StoredValueInput {
|
|
7
|
+
count: number;
|
|
8
|
+
firstRequest: TimestampInput;
|
|
9
|
+
lastRequest: TimestampInput;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StoredValue {
|
|
13
|
+
count: number;
|
|
14
|
+
firstRequest: Date;
|
|
15
|
+
lastRequest: Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Options {
|
|
19
|
+
createTable?: boolean;
|
|
20
|
+
knex?: Knex;
|
|
21
|
+
tablename?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Defaults {
|
|
25
|
+
createTable: boolean;
|
|
26
|
+
tablename: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ResultCallback<TResult = unknown> = (error: Error | null, result?: TResult) => void;
|
|
30
|
+
type GetCallback = (error: Error | null, value?: StoredValue | null) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare class KnexStore {
|
|
34
|
+
static defaults: KnexStore.Defaults;
|
|
35
|
+
static defaultsKnex: Knex.Config;
|
|
36
|
+
|
|
37
|
+
constructor(options?: KnexStore.Options);
|
|
38
|
+
|
|
39
|
+
knex: Knex;
|
|
40
|
+
options: KnexStore.Options & KnexStore.Defaults;
|
|
41
|
+
ready: Promise<void>;
|
|
42
|
+
|
|
43
|
+
set(
|
|
44
|
+
key: string,
|
|
45
|
+
value: KnexStore.StoredValueInput,
|
|
46
|
+
lifetime: number,
|
|
47
|
+
callback?: KnexStore.ResultCallback,
|
|
48
|
+
): Promise<unknown>;
|
|
49
|
+
|
|
50
|
+
get(key: string, callback?: KnexStore.GetCallback): Promise<KnexStore.StoredValue | null>;
|
|
51
|
+
|
|
52
|
+
reset(key: string, callback?: KnexStore.ResultCallback): Promise<unknown>;
|
|
53
|
+
|
|
54
|
+
increment(key: string, lifetime: number, callback?: KnexStore.ResultCallback): Promise<unknown>;
|
|
55
|
+
|
|
56
|
+
clearExpired(callback?: KnexStore.ResultCallback): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export = KnexStore;
|
package/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
var AbstractClientStore = require('express-brute/lib/AbstractClientStore');
|
|
3
|
+
var _ = require('lodash');
|
|
4
|
+
|
|
5
|
+
function rethrowAsync(error) {
|
|
6
|
+
setTimeout(function () {
|
|
7
|
+
throw error;
|
|
8
|
+
}, 0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function withCallback(promise, callback) {
|
|
12
|
+
promise = Promise.resolve(promise);
|
|
13
|
+
|
|
14
|
+
if (typeof callback === 'function') {
|
|
15
|
+
var callbackPromise = promise.then(
|
|
16
|
+
function (result) {
|
|
17
|
+
callback(null, result);
|
|
18
|
+
},
|
|
19
|
+
function (error) {
|
|
20
|
+
callback(error);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
callbackPromise.catch(rethrowAsync);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return promise;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* we are using bigInteger to store a UTC timestamp
|
|
32
|
+
* alternative would be using moment-timezone to store YYYY-MM-DD HH:mm:ss (but it does not contain ms)
|
|
33
|
+
*
|
|
34
|
+
* @type {module.exports}
|
|
35
|
+
*/
|
|
36
|
+
var KnexStore = (module.exports = function (options) {
|
|
37
|
+
var self = this;
|
|
38
|
+
|
|
39
|
+
options = options || {};
|
|
40
|
+
|
|
41
|
+
AbstractClientStore.apply(this, arguments);
|
|
42
|
+
this.options = _.extend({}, KnexStore.defaults, options);
|
|
43
|
+
|
|
44
|
+
if (this.options.knex) {
|
|
45
|
+
this.knex = this.options.knex;
|
|
46
|
+
} else {
|
|
47
|
+
this.knex = require('knex')(KnexStore.defaultsKnex);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.createTable === false) {
|
|
51
|
+
self.ready = Promise.resolve();
|
|
52
|
+
} else {
|
|
53
|
+
self.ready = self.knex.schema.hasTable(self.options.tablename).then(function (exists) {
|
|
54
|
+
if (!exists) {
|
|
55
|
+
return self.knex.schema.createTable(self.options.tablename, function (table) {
|
|
56
|
+
table.string('key');
|
|
57
|
+
table.bigInteger('firstRequest').nullable();
|
|
58
|
+
table.bigInteger('lastRequest').nullable();
|
|
59
|
+
table.bigInteger('lifetime').nullable();
|
|
60
|
+
table.integer('count');
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
self.ready = Promise.resolve(self.ready);
|
|
67
|
+
});
|
|
68
|
+
KnexStore.prototype = Object.create(AbstractClientStore.prototype);
|
|
69
|
+
|
|
70
|
+
KnexStore.prototype.set = function (key, value, lifetime, callback) {
|
|
71
|
+
var self = this;
|
|
72
|
+
lifetime = lifetime || 0;
|
|
73
|
+
|
|
74
|
+
return withCallback(
|
|
75
|
+
self.ready.then(function () {
|
|
76
|
+
return self.knex.transaction(function (trx) {
|
|
77
|
+
return trx
|
|
78
|
+
.select('*')
|
|
79
|
+
.forUpdate()
|
|
80
|
+
.from(self.options.tablename)
|
|
81
|
+
.where('key', '=', key)
|
|
82
|
+
.then(function (foundKeys) {
|
|
83
|
+
if (foundKeys.length == 0) {
|
|
84
|
+
return trx.from(self.options.tablename).insert({
|
|
85
|
+
key: key,
|
|
86
|
+
lifetime: new Date(Date.now() + lifetime * 1000).getTime(),
|
|
87
|
+
lastRequest: new Date(value.lastRequest).getTime(),
|
|
88
|
+
firstRequest: new Date(value.firstRequest).getTime(),
|
|
89
|
+
count: value.count,
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
return trx(self.options.tablename)
|
|
93
|
+
.where('key', '=', key)
|
|
94
|
+
.update({
|
|
95
|
+
lifetime: new Date(Date.now() + lifetime * 1000).getTime(),
|
|
96
|
+
count: value.count,
|
|
97
|
+
lastRequest: new Date(value.lastRequest).getTime(),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}),
|
|
103
|
+
callback,
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
KnexStore.prototype.get = function (key, callback) {
|
|
108
|
+
var self = this;
|
|
109
|
+
return withCallback(
|
|
110
|
+
self.ready
|
|
111
|
+
.then(function () {
|
|
112
|
+
return self.clearExpired();
|
|
113
|
+
})
|
|
114
|
+
.then(function () {
|
|
115
|
+
return self.knex.select('*').from(self.options.tablename).where('key', '=', key);
|
|
116
|
+
})
|
|
117
|
+
.then(function (response) {
|
|
118
|
+
var o = null;
|
|
119
|
+
if (response[0]) {
|
|
120
|
+
o = {};
|
|
121
|
+
o.lastRequest = new Date(response[0].lastRequest);
|
|
122
|
+
o.firstRequest = new Date(response[0].firstRequest);
|
|
123
|
+
o.count = response[0].count;
|
|
124
|
+
}
|
|
125
|
+
return o;
|
|
126
|
+
}),
|
|
127
|
+
callback,
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
KnexStore.prototype.reset = function (key, callback) {
|
|
131
|
+
var self = this;
|
|
132
|
+
return withCallback(
|
|
133
|
+
self.ready.then(function () {
|
|
134
|
+
return self.knex(self.options.tablename).where('key', '=', key).del();
|
|
135
|
+
}),
|
|
136
|
+
callback,
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
KnexStore.prototype.increment = function (key, lifetime, callback) {
|
|
141
|
+
var self = this;
|
|
142
|
+
return withCallback(
|
|
143
|
+
self.get(key).then(function (result) {
|
|
144
|
+
if (result) {
|
|
145
|
+
return self
|
|
146
|
+
.knex(self.options.tablename)
|
|
147
|
+
.increment('count', 1)
|
|
148
|
+
.where('key', '=', key);
|
|
149
|
+
} else {
|
|
150
|
+
return self.knex(self.options.tablename).insert({
|
|
151
|
+
key: key,
|
|
152
|
+
firstRequest: new Date().getTime(),
|
|
153
|
+
lastRequest: new Date().getTime(),
|
|
154
|
+
lifetime: new Date(Date.now() + lifetime * 1000).getTime(),
|
|
155
|
+
count: 1,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}),
|
|
159
|
+
callback,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
KnexStore.prototype.clearExpired = function (callback) {
|
|
164
|
+
var self = this;
|
|
165
|
+
return withCallback(
|
|
166
|
+
self.ready.then(function () {
|
|
167
|
+
return self
|
|
168
|
+
.knex(self.options.tablename)
|
|
169
|
+
.del()
|
|
170
|
+
.where('lifetime', '<', new Date().getTime());
|
|
171
|
+
}),
|
|
172
|
+
callback,
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
KnexStore.defaults = {
|
|
177
|
+
tablename: 'brute',
|
|
178
|
+
createTable: true,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
KnexStore.defaultsKnex = {
|
|
182
|
+
client: 'sqlite3',
|
|
183
|
+
// debug: true,
|
|
184
|
+
connection: {
|
|
185
|
+
filename: './brute-knex.sqlite',
|
|
186
|
+
},
|
|
187
|
+
useNullAsDefault: true,
|
|
188
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tryghost/brute-knex",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A Knex.js store for express-brute",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"express",
|
|
7
|
+
"brute",
|
|
8
|
+
"knex"
|
|
9
|
+
],
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"private": false,
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"main": "index.js",
|
|
16
|
+
"types": "index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"index.d.ts",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.20.0"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git@github.com:TryGhost/brute-knex.git"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@tryghost/pro-ship": "1.1.4",
|
|
32
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
33
|
+
"mysql2": "3.22.5",
|
|
34
|
+
"oxfmt": "0.56.0",
|
|
35
|
+
"oxlint": "1.71.0",
|
|
36
|
+
"pg": "8.22.0",
|
|
37
|
+
"sqlite3": "6.0.1",
|
|
38
|
+
"typescript": "6.0.3",
|
|
39
|
+
"vitest": "4.1.9"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"express-brute": "1.0.1",
|
|
43
|
+
"knex": "2.5.1",
|
|
44
|
+
"lodash": "4.18.1"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"format": "oxfmt .",
|
|
48
|
+
"lint": "oxlint --quiet && oxfmt --check . && pnpm run test:types",
|
|
49
|
+
"lint:fix": "oxlint --fix && oxfmt .",
|
|
50
|
+
"preship": "pnpm lint && pnpm test",
|
|
51
|
+
"ship": "pro-ship --publish",
|
|
52
|
+
"test": "vitest run --coverage",
|
|
53
|
+
"test:e2e:sqlite": "BRUTE_KNEX_DIALECTS=sqlite vitest run --coverage",
|
|
54
|
+
"test:e2e:mysql": "BRUTE_KNEX_DIALECTS=mysql vitest run --coverage",
|
|
55
|
+
"test:e2e:postgres": "BRUTE_KNEX_DIALECTS=postgres vitest run --coverage",
|
|
56
|
+
"test:types": "tsc --noEmit -p tsconfig.types.json"
|
|
57
|
+
}
|
|
58
|
+
}
|