@20syldev/api 4.4.0 → 4.5.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 (48) hide show
  1. package/README.md +3 -3
  2. package/dist/config/env.js +5 -1
  3. package/dist/config/env.js.map +1 -1
  4. package/dist/config/plans.js +44 -0
  5. package/dist/config/plans.js.map +1 -0
  6. package/dist/constants.js +1 -0
  7. package/dist/constants.js.map +1 -1
  8. package/dist/middleware/cors.js +7 -2
  9. package/dist/middleware/cors.js.map +1 -1
  10. package/dist/middleware/error.js +3 -1
  11. package/dist/middleware/error.js.map +1 -1
  12. package/dist/middleware/ratelimit.js +39 -21
  13. package/dist/middleware/ratelimit.js.map +1 -1
  14. package/dist/modules/v3/chat.js +1 -1
  15. package/dist/modules/v3/chat.js.map +1 -1
  16. package/dist/modules/v3/domain.js +1 -1
  17. package/dist/modules/v3/domain.js.map +1 -1
  18. package/dist/modules/v3/hyperplanning.js +1 -1
  19. package/dist/modules/v3/hyperplanning.js.map +1 -1
  20. package/dist/modules/v3/personal.js +1 -1
  21. package/dist/modules/v3/personal.js.map +1 -1
  22. package/dist/modules/v3/username.js +1 -1
  23. package/dist/modules/v3/username.js.map +1 -1
  24. package/dist/modules/v4/convert.js.map +1 -1
  25. package/dist/modules/v4/hyperplanning.js +31 -1
  26. package/dist/modules/v4/hyperplanning.js.map +1 -1
  27. package/dist/routes/get.js +9 -8
  28. package/dist/routes/get.js.map +1 -1
  29. package/dist/routes/post.js +2 -1
  30. package/dist/routes/post.js.map +1 -1
  31. package/dist/utils/colors.js +59 -20
  32. package/dist/utils/colors.js.map +1 -1
  33. package/dist/utils/helpers.js +67 -16
  34. package/dist/utils/helpers.js.map +1 -1
  35. package/dist/utils/response.js +18 -2
  36. package/dist/utils/response.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/config/env.ts +6 -1
  39. package/src/config/plans.ts +61 -0
  40. package/src/constants.ts +1 -0
  41. package/src/middleware/cors.ts +8 -2
  42. package/src/middleware/error.ts +10 -2
  43. package/src/middleware/ratelimit.ts +42 -20
  44. package/src/modules/v4/hyperplanning.ts +27 -1
  45. package/src/routes/get.ts +4 -4
  46. package/src/utils/response.ts +10 -2
  47. package/tests/integration/api.test.ts +38 -0
  48. package/tests/unit/hyperplanning.test.ts +19 -0
@@ -1,45 +1,53 @@
1
1
  import type { Request, Response, NextFunction } from 'express';
2
- import { env } from '../config/env.js';
3
2
  import { ipLimits } from '../storage/index.js';
4
- import { SESSION_TTL } from '../constants.js';
3
+ import { RATE_LIMIT_WINDOW, SESSION_TTL } from '../constants.js';
5
4
  import { error } from '../utils/response.js';
5
+ import { getPlan, globalLimit } from '../config/plans.js';
6
6
 
7
7
  let requests = 0;
8
8
  let resetTime = Date.now() + SESSION_TTL;
9
9
 
10
+ const burstTracker: Record<string, number[]> = {};
11
+
12
+ // Cleanup stale IPs every hour
13
+ setInterval(() => {
14
+ const currentHour = String(Math.floor(Date.now() / 3600000) % 24);
15
+ for (const ip of Object.keys(ipLimits)) {
16
+ const hours = Object.keys(ipLimits[ip]!);
17
+ if (hours.length === 0 || (hours.length === 1 && hours[0] !== currentHour)) {
18
+ delete ipLimits[ip];
19
+ }
20
+ }
21
+
22
+ const now = Date.now();
23
+ for (const ip of Object.keys(burstTracker)) {
24
+ burstTracker[ip] = burstTracker[ip]!.filter((t) => now - t < RATE_LIMIT_WINDOW);
25
+ if (burstTracker[ip]!.length === 0) delete burstTracker[ip];
26
+ }
27
+ }, SESSION_TTL);
28
+
10
29
  export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
11
- const ip = (req.headers['cf-connecting-ip'] as string) || req.socket.remoteAddress || '';
30
+ const ip = req.ip || req.socket.remoteAddress || '';
12
31
  const token = req.headers.authorization?.split(' ')[1] || '';
13
32
 
14
33
  const now = Date.now();
15
34
  const minute = String(Math.floor(now / 60000) % 60);
16
35
  const hour = String(Math.floor(now / 3600000) % 24);
17
36
 
18
- if (
19
- (token && ![...env.BUSINESS_TOKEN_LIST, ...env.PRO_TOKEN_LIST, ...env.ADVANCED_TOKEN_LIST].includes(token)) ||
20
- token === 'undefined'
21
- ) {
37
+ const match = getPlan(token);
38
+ if (!match) {
22
39
  error(res, 401, 'Invalid token.');
23
40
  return;
24
41
  }
25
42
 
26
- let requestLimit: number;
27
- if (env.BUSINESS_TOKEN_LIST.includes(token) && !env.BUSINESS_TOKEN_LIST.includes('undefined')) {
28
- requestLimit = env.BUSINESS_LIMIT;
29
- } else if (env.PRO_TOKEN_LIST.includes(token) && !env.PRO_TOKEN_LIST.includes('undefined')) {
30
- requestLimit = env.PRO_LIMIT;
31
- } else if (env.ADVANCED_TOKEN_LIST.includes(token) && !env.ADVANCED_TOKEN_LIST.includes('undefined')) {
32
- requestLimit = env.ADVANCED_LIMIT;
33
- } else {
34
- requestLimit = env.DEFAULT_LIMIT;
35
- }
43
+ const { plan } = match;
36
44
 
37
45
  if (now > resetTime) {
38
46
  requests = 0;
39
47
  resetTime = now + SESSION_TTL;
40
48
  }
41
49
 
42
- if (++requests > Math.max(env.GLOBAL_LIMIT, requestLimit)) {
50
+ if (++requests > Math.max(globalLimit, plan.hourly)) {
43
51
  error(res, 429, 'Global rate limit exceeded.');
44
52
  return;
45
53
  }
@@ -49,12 +57,26 @@ export function rateLimitMiddleware(req: Request, res: Response, next: NextFunct
49
57
  return;
50
58
  }
51
59
 
60
+ // Burst protection per tier
61
+ if (process.env.NODE_ENV !== 'test') {
62
+ if (!burstTracker[ip]) burstTracker[ip] = [];
63
+ burstTracker[ip] = burstTracker[ip]!.filter((t) => now - t < RATE_LIMIT_WINDOW);
64
+ burstTracker[ip]!.push(now);
65
+
66
+ if (burstTracker[ip]!.length > plan.burst) {
67
+ error(res, 429, 'Too many requests, please slow down.');
68
+ return;
69
+ }
70
+ }
71
+
72
+ // Hourly rate limit per IP
52
73
  if (!ipLimits[ip]) ipLimits[ip] = {};
53
74
  if (!ipLimits[ip]![hour]) ipLimits[ip]![hour] = {};
54
75
  ipLimits[ip]![hour]![minute] = (ipLimits[ip]![hour]![minute] ?? 0) + 1;
55
76
 
56
- if (ipLimits[ip]![hour]![minute]! > requestLimit) {
57
- error(res, 429, `You have exceeded the limit of ${requestLimit} requests per hour.`);
77
+ const hourTotal = Object.values(ipLimits[ip]![hour]!).reduce((sum, count) => sum + count, 0);
78
+ if (hourTotal > plan.hourly) {
79
+ error(res, 429, `You have exceeded the limit of ${plan.hourly} requests per hour.`);
58
80
  return;
59
81
  }
60
82
 
@@ -11,8 +11,34 @@ interface CalendarEvent {
11
11
  end: string;
12
12
  }
13
13
 
14
+ function blocked(hostname: string): boolean {
15
+ const list = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]', 'metadata.google.internal'];
16
+ if (list.includes(hostname)) return true;
17
+
18
+ const parts = hostname.split('.').map(Number);
19
+ if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
20
+ if (parts[0] === 10) return true;
21
+ if (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) return true;
22
+ if (parts[0] === 192 && parts[1] === 168) return true;
23
+ if (parts[0] === 169 && parts[1] === 254) return true;
24
+ if (parts[0] === 0) return true;
25
+ }
26
+
27
+ return false;
28
+ }
29
+
14
30
  export default async function hyperplanning(url: string, detail?: string): Promise<CalendarEvent[]> {
15
- const response = await fetch(url);
31
+ let parsed: URL;
32
+ try {
33
+ parsed = new URL(url);
34
+ } catch {
35
+ throw new Error('Invalid URL.');
36
+ }
37
+
38
+ if (parsed.protocol !== 'https:') throw new Error('Only HTTPS URLs are allowed.');
39
+ if (blocked(parsed.hostname)) throw new Error('Access to private/internal hosts is not allowed.');
40
+
41
+ const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
16
42
 
17
43
  if (!response.ok || !(response.headers.get('content-type') || '').includes('text/calendar')) {
18
44
  throw new Error('Invalid ICS file format.');
package/src/routes/get.ts CHANGED
@@ -61,7 +61,7 @@ router.get('/:version/algorithms', (req: Request, res: Response) => {
61
61
  const { version } = req.params;
62
62
 
63
63
  const algorithms = req.module.algorithms as Record<string, (v: string, v2?: string) => unknown>;
64
- if (!algorithms || !algorithms[method as string]) {
64
+ if (!algorithms || !method || !Object.hasOwn(algorithms, method as string)) {
65
65
  error(res, 400, 'Please provide a valid algorithm (?method={algorithm})', `${version}/algorithms`);
66
66
  return;
67
67
  }
@@ -217,7 +217,7 @@ router.get('/:version/encode', (req: Request, res: Response) => {
217
217
  error(res, 404, `Endpoint not available in ${version}.`, `${version}/encode`);
218
218
  return;
219
219
  }
220
- if (!method || !encode[method as string]) {
220
+ if (!method || !Object.hasOwn(encode, method as string)) {
221
221
  error(res, 400, 'Please provide a valid method (?method={method})', `${version}/encode`);
222
222
  return;
223
223
  }
@@ -434,7 +434,7 @@ router.get('/:version/text', (req: Request, res: Response) => {
434
434
  error(res, 404, `Endpoint not available in ${version}.`, `${version}/text`);
435
435
  return;
436
436
  }
437
- if (!method || !textMod[method as string]) {
437
+ if (!method || !Object.hasOwn(textMod, method as string)) {
438
438
  error(res, 400, 'Please provide a valid method (?method={slug|stats|lorem|number})', `${version}/text`);
439
439
  return;
440
440
  }
@@ -471,7 +471,7 @@ router.get('/:version/validate', (req: Request, res: Response) => {
471
471
  error(res, 404, `Endpoint not available in ${version}.`, `${version}/validate`);
472
472
  return;
473
473
  }
474
- if (!type || !validate[type as string]) {
474
+ if (!type || !Object.hasOwn(validate, type as string)) {
475
475
  error(res, 400, 'Please provide a valid type (?type={luhn|iban|email})', `${version}/validate`);
476
476
  return;
477
477
  }
@@ -10,10 +10,18 @@ import { DOCS_URL, STATUS_MESSAGES } from '../constants.js';
10
10
  * @param docPath - Optional documentation path appended to the base URL
11
11
  */
12
12
  export function error(res: Response, status: number, message: string, docPath?: string): void {
13
- res.status(status).jsonResponse({
13
+ const body = {
14
14
  message: STATUS_MESSAGES[status] ?? 'Error',
15
15
  error: message,
16
16
  documentation: docPath ? `${DOCS_URL}/${docPath}` : DOCS_URL,
17
17
  status: String(status),
18
- });
18
+ };
19
+
20
+ if (typeof res.jsonResponse === 'function') {
21
+ res.status(status).jsonResponse(body);
22
+ } else {
23
+ res.status(status)
24
+ .setHeader('Content-Type', 'application/json')
25
+ .send(JSON.stringify(body, null, 2));
26
+ }
19
27
  }
@@ -536,3 +536,41 @@ describe('POST /v4/token', () => {
536
536
  assert.equal(status, 400);
537
537
  });
538
538
  });
539
+
540
+ // --- Security ---
541
+
542
+ describe('Security headers', () => {
543
+ test('returns X-Content-Type-Options and X-Frame-Options', async () => {
544
+ const res = await fetch(`${baseUrl}/v4/color`);
545
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
546
+ assert.equal(res.headers.get('x-frame-options'), 'DENY');
547
+ });
548
+ });
549
+
550
+ describe('Payload size limit', () => {
551
+ test('body > 10kb returns 413', async () => {
552
+ const res = await fetch(`${baseUrl}/v4/hash`, {
553
+ method: 'POST',
554
+ headers: { 'Content-Type': 'application/json' },
555
+ body: JSON.stringify({ text: 'A'.repeat(20000), method: 'sha256' }),
556
+ });
557
+ assert.equal(res.status, 413);
558
+ });
559
+ });
560
+
561
+ describe('Prototype access on dynamic endpoints', () => {
562
+ test('algorithms?method=toString returns 400', async () => {
563
+ const { status } = await getJson('/v4/algorithms?method=toString');
564
+ assert.equal(status, 400);
565
+ });
566
+
567
+ test('encode?method=constructor returns 400', async () => {
568
+ const { status } = await getJson('/v4/encode?method=constructor&text=hello');
569
+ assert.equal(status, 400);
570
+ });
571
+
572
+ test('validate?type=hasOwnProperty returns 400', async () => {
573
+ const { status } = await getJson('/v4/validate?type=hasOwnProperty&value=test');
574
+ assert.equal(status, 400);
575
+ });
576
+ });
@@ -70,6 +70,25 @@ describe('hyperplanning', () => {
70
70
  const events = await hyperplanning('https://fake.test/cal.ics');
71
71
  assert.equal(events.length, 0);
72
72
  });
73
+
74
+ test('throws on HTTP URL', async () => {
75
+ await assert.rejects(() => hyperplanning('http://fake.test/cal.ics'), /Only HTTPS/);
76
+ });
77
+
78
+ test('throws on private IP', async () => {
79
+ await assert.rejects(() => hyperplanning('https://127.0.0.1/cal.ics'), /private/);
80
+ await assert.rejects(() => hyperplanning('https://192.168.1.1/cal.ics'), /private/);
81
+ await assert.rejects(() => hyperplanning('https://10.0.0.1/cal.ics'), /private/);
82
+ await assert.rejects(() => hyperplanning('https://169.254.169.254/latest'), /private/);
83
+ });
84
+
85
+ test('throws on localhost', async () => {
86
+ await assert.rejects(() => hyperplanning('https://localhost/cal.ics'), /private/);
87
+ });
88
+
89
+ test('throws on invalid URL', async () => {
90
+ await assert.rejects(() => hyperplanning('not-a-url'), /Invalid URL/);
91
+ });
73
92
  });
74
93
 
75
94
  before(() => {