File size: 2,983 Bytes
9509f5b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import type { IncomingMessage } from "node:http";
import { isIP } from "node:net";
import { argon2Verify } from "hash-wasm";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { getSearchToken } from "./searchToken.ts";
import { addVerifiedToken, isVerifiedToken } from "./verifiedTokens.ts";

const rateLimiter = new RateLimiterMemory({
  points: 10,
  duration: 10,
});

/** Whether to trust proxy-set forwarding headers. Off unless `TRUST_PROXY` is `true`/`1`. */
function isProxyTrusted(): boolean {
  const value = process.env.TRUST_PROXY?.trim().toLowerCase();
  return value === "true" || value === "1";
}

/**
 * Resolves the client IP used as the rate-limit key.
 *
 * `X-Forwarded-For` / `X-Real-IP` are only honored when `TRUST_PROXY` is
 * enabled. On a directly-exposed instance those headers are fully
 * client-controlled, so trusting them would let a caller forge a fresh IP per
 * request and evade rate limiting entirely. When `TRUST_PROXY` is off (the
 * default) we use the real TCP peer address, which cannot be spoofed.
 *
 * Enable `TRUST_PROXY` only when MiniSearch runs behind a reverse proxy that
 * sets the rightmost `X-Forwarded-For` entry (e.g. nginx's
 * `$proxy_add_x_forwarded_for`).
 */
export function getClientIp(request: IncomingMessage): string {
  if (isProxyTrusted()) {
    const forwarded = request.headers["x-forwarded-for"];
    const xff = Array.isArray(forwarded) ? forwarded.join(",") : forwarded;
    if (typeof xff === "string" && xff.length > 0) {
      const parts = xff
        .split(",")
        .map((p) => p.trim())
        .filter(Boolean);
      const ip = parts[parts.length - 1];
      if (ip && isIP(ip)) {
        return ip;
      }
    }
    const realIp = request.headers["x-real-ip"];
    if (typeof realIp === "string" && realIp.length > 0 && isIP(realIp)) {
      return realIp;
    }
  }
  return request.socket.remoteAddress || "unknown";
}

export async function verifyTokenAndRateLimit(
  token: string | null,
  request?: IncomingMessage,
): Promise<{
  isAuthorized: boolean;
  statusCode?: number;
  error?: string;
}> {
  if (!token) {
    return {
      isAuthorized: false,
      statusCode: 400,
      error: "Missing token.",
    };
  }

  if (!isVerifiedToken(token)) {
    let isValidToken = false;

    try {
      isValidToken = await argon2Verify({
        password: getSearchToken(),
        hash: token,
      });
    } catch (error) {
      void error;
    }

    if (!isValidToken) {
      return {
        isAuthorized: false,
        statusCode: 401,
        error: "Invalid token.",
      };
    }
  }

  // Records a new session or refreshes an active one's last-seen time.
  addVerifiedToken(token);

  const rateLimitKey = request ? getClientIp(request) : token;

  try {
    await rateLimiter.consume(rateLimitKey);
  } catch {
    return {
      isAuthorized: false,
      statusCode: 429,
      error: "Too many requests.",
    };
  }

  return { isAuthorized: true };
}