Spaces:
Paused
Paused
File size: 4,960 Bytes
ff7a361 | 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 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | import type { IncomingMessage, ServerResponse } from "node:http";
import type { RateLimiterMemory } from "rate-limiter-flexible";
import {
type AuthorizationSurface,
recordAuthorizedRequest,
recordRejectedRequest,
} from "./authorizationSinceLastRestart.ts";
import {
type RejectionReason,
verifyTokenAndRateLimit,
} from "./verifyTokenAndRateLimit.ts";
const SURFACES: [pathPrefix: string, surface: AuthorizationSurface][] = [
["/search", "search"],
["/page-content", "pageContent"],
["/thumbnail", "thumbnail"],
["/inference", "inference"],
];
/**
* The rejections a person can land on by opening a search URL that was
* bookmarked, shared or embedded before this instance rotated its token, or
* one that had the token stripped from it on the way. A rate-limited request
* is not one of them: the same URL works again a few seconds later, and the
* page below would be telling that person the wrong story.
*/
const REASONS_WITH_A_PAGE: ReadonlySet<RejectionReason> = new Set([
"missingToken",
"invalidToken",
]);
/**
* Static on purpose: nothing from the request goes in, so a caller cannot get
* its own query, URL or header reflected back in a `text/html` body. The page
* links to the root, where the app takes a token through `/api/config` the way
* it always does; it carries no token itself and redirects nowhere, because
* handing a token to any caller that turned up with a bad one would remove the
* CSRF gate (`docs/security.md`, Search Token Lifecycle). No script and no
* external asset, so it renders even where the instance is the only reachable
* host.
*/
const EXPIRED_SEARCH_LINK_PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>This search link has expired</title>
<style>
body { margin: 0; padding: 3rem 1.5rem; font: 1rem/1.6 system-ui, sans-serif; color: #1a1a1a; background: #fff; }
main { max-width: 36rem; margin: 0 auto; }
h1 { font-size: 1.5rem; line-height: 1.3; }
a { color: #0b5fff; }
@media (prefers-color-scheme: dark) { body { color: #ededed; background: #121212; } a { color: #8ab4ff; } }
</style>
</head>
<body>
<main>
<h1>This search link has expired</h1>
<p>This MiniSearch instance rotated its search token. Every search URL carries that token, so a bookmarked, shared or embedded search URL stops working when the token changes.</p>
<p><a href="/">Open MiniSearch</a> and search again from there. A search started on that page uses the current token.</p>
</main>
</body>
</html>
`;
/**
* The endpoint family a request was aimed at. `request.url` carries the query
* string too, but the match is anchored at the start of it and only the label
* is kept, so no part of a query can reach a counter or change one.
*/
function resolveSurface(request?: IncomingMessage): AuthorizationSurface {
const path = request?.url ?? "";
const match = SURFACES.find(([pathPrefix]) => path.startsWith(pathPrefix));
return match ? match[1] : "other";
}
/**
* Whether the request looks like a browser navigation, which means its
* `Accept` header asks for `text/html` by name. A navigation sends
* `text/html,application/xhtml+xml,...`, while `fetch` sends only the wildcard
* and API clients send `application/json` or nothing, and all of those keep
* the JSON body. An entry with `q=0` is a refusal, not a request, so it does
* not count either.
*/
function asksForHtml(request?: IncomingMessage): boolean {
const accept = request?.headers.accept;
if (typeof accept !== "string") return false;
return accept.split(",").some((entry) => {
const [mediaType, ...parameters] = entry.split(";");
if (mediaType.trim().toLowerCase() !== "text/html") return false;
return !parameters.some((parameter) => {
const [name, value] = parameter.split("=");
return (
name.trim().toLowerCase() === "q" && Number.parseFloat(value) === 0
);
});
});
}
export async function handleTokenVerification(
token: string | null,
response: ServerResponse,
request?: IncomingMessage,
options?: { limiter?: RateLimiterMemory },
): Promise<{ shouldContinue: boolean }> {
const result = await verifyTokenAndRateLimit(
token,
request,
options?.limiter,
);
const surface = resolveSurface(request);
if (!result.isAuthorized) {
recordRejectedRequest(surface, result.reason);
response.statusCode = result.statusCode;
if (REASONS_WITH_A_PAGE.has(result.reason) && asksForHtml(request)) {
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(EXPIRED_SEARCH_LINK_PAGE);
return { shouldContinue: false };
}
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify({ error: result.error }));
return { shouldContinue: false };
}
recordAuthorizedRequest(surface);
return { shouldContinue: true };
}
|