tokentrace 0.18.1 → 0.19.1
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/CHANGELOG.md +36 -0
- package/app/api/import-profile-preview/route.ts +16 -1
- package/app/api/parser-debug/preview/route.ts +17 -3
- package/next.config.mjs +38 -1
- package/package.json +2 -1
- package/server.json +2 -2
- package/src/cli/serve.d.ts +32 -0
- package/src/cli/serve.js +23 -0
- package/src/lib/path-access.ts +99 -0
- package/src/lib/request-guard.ts +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ All notable changes to TokenTrace are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.19.1] - 2026-06-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Leaner npm package.** The published tarball no longer includes a set of
|
|
12
|
+
non-product brand assets (~350 KB of images) that had been parked in the
|
|
13
|
+
`public/` directory and were unintentionally swept into the package by the
|
|
14
|
+
`files` allowlist. They are now excluded from the tarball and ignored by git.
|
|
15
|
+
No functional change — installs are simply smaller and contain only product
|
|
16
|
+
files.
|
|
17
|
+
|
|
18
|
+
## [0.19.0] - 2026-06-04
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
|
|
22
|
+
- **The local dashboard now enforces a request perimeter.** All `/api/*` routes
|
|
23
|
+
go through `middleware.ts`, which rejects requests whose `Host` is not a
|
|
24
|
+
loopback name (defeating DNS-rebinding that would otherwise let a malicious
|
|
25
|
+
web page read your local data as "same-origin") and blocks cross-site
|
|
26
|
+
state-changing requests (defeating CSRF that could silently re-point the
|
|
27
|
+
scanner, change settings, or wipe imported data). Non-browser clients (CLI,
|
|
28
|
+
curl) are unaffected.
|
|
29
|
+
- **File-preview endpoints are now contained.** `/api/parser-debug/preview` and
|
|
30
|
+
`/api/import-profile-preview` previously read any caller-supplied path. They
|
|
31
|
+
now resolve symlinks and only read files under your home directory, the OS
|
|
32
|
+
temp directory, or explicitly configured import folders, returning `403` for
|
|
33
|
+
anything else. This removes an arbitrary-file-read surface.
|
|
34
|
+
- **`tokentrace serve` refuses non-loopback binds by default.** Binding to
|
|
35
|
+
`0.0.0.0` or a LAN address (via `--hostname` or `TOKENTRACE_HOSTNAME`) now
|
|
36
|
+
errors with guidance, since the dashboard is unauthenticated. Set
|
|
37
|
+
`TOKENTRACE_ALLOW_REMOTE=1` to override deliberately.
|
|
38
|
+
- **Security response headers added.** The dashboard now sends a strict
|
|
39
|
+
same-origin `Content-Security-Policy`, `X-Frame-Options: DENY`,
|
|
40
|
+
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and a
|
|
41
|
+
restrictive `Permissions-Policy` (anti-clickjacking and anti-sniffing).
|
|
42
|
+
|
|
7
43
|
## [0.18.1] - 2026-05-28
|
|
8
44
|
|
|
9
45
|
### Fixed
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { getAppSettings } from "@/src/db/settings";
|
|
2
3
|
import { readJsonObject } from "@/src/lib/api-json";
|
|
3
4
|
import { buildImportProfilePreview } from "@/src/lib/import-profile-preview";
|
|
5
|
+
import { PathAccessError, pathAccessStatus, resolveReadablePath } from "@/src/lib/path-access";
|
|
4
6
|
|
|
5
7
|
export const dynamic = "force-dynamic";
|
|
6
8
|
|
|
@@ -11,9 +13,22 @@ export async function POST(request: Request) {
|
|
|
11
13
|
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
12
14
|
return NextResponse.json({ error: "filePath is required." }, { status: 400 });
|
|
13
15
|
}
|
|
16
|
+
|
|
17
|
+
let resolvedPath: string;
|
|
18
|
+
try {
|
|
19
|
+
resolvedPath = await resolveReadablePath(filePath, {
|
|
20
|
+
extraRoots: getAppSettings().customFolders
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof PathAccessError) {
|
|
24
|
+
return NextResponse.json({ error: error.message }, { status: pathAccessStatus(error.code) });
|
|
25
|
+
}
|
|
26
|
+
return NextResponse.json({ error: "Preview failed." }, { status: 400 });
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
try {
|
|
15
30
|
const preview = await buildImportProfilePreview({
|
|
16
|
-
filePath:
|
|
31
|
+
filePath: resolvedPath,
|
|
17
32
|
storeRawMessageContent: parsed.body.storeRawMessageContent === true
|
|
18
33
|
});
|
|
19
34
|
return NextResponse.json(preview);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { NextResponse } from "next/server";
|
|
3
|
+
import { getAppSettings } from "@/src/db/settings";
|
|
3
4
|
import { adapters } from "@/src/ingestion/adapters";
|
|
4
5
|
import type { FileCandidate, NormalizedInteraction } from "@/src/ingestion/types";
|
|
6
|
+
import { PathAccessError, pathAccessStatus, resolveReadablePath } from "@/src/lib/path-access";
|
|
5
7
|
|
|
6
8
|
export const dynamic = "force-dynamic";
|
|
7
9
|
|
|
@@ -18,10 +20,10 @@ export async function POST(request: Request) {
|
|
|
18
20
|
return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 });
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const
|
|
23
|
+
const requestedPath = typeof body.path === "string" ? body.path.trim() : "";
|
|
22
24
|
const parserId = typeof body.parserId === "string" ? body.parserId.trim() : "";
|
|
23
25
|
|
|
24
|
-
if (!
|
|
26
|
+
if (!requestedPath) {
|
|
25
27
|
return NextResponse.json({ error: "path is required" }, { status: 400 });
|
|
26
28
|
}
|
|
27
29
|
if (!parserId) {
|
|
@@ -36,11 +38,23 @@ export async function POST(request: Request) {
|
|
|
36
38
|
);
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
let filePath: string;
|
|
42
|
+
try {
|
|
43
|
+
filePath = await resolveReadablePath(requestedPath, {
|
|
44
|
+
extraRoots: getAppSettings().customFolders
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof PathAccessError) {
|
|
48
|
+
return NextResponse.json({ error: error.message }, { status: pathAccessStatus(error.code) });
|
|
49
|
+
}
|
|
50
|
+
return NextResponse.json({ error: `file not found: ${requestedPath}` }, { status: 404 });
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
let stat;
|
|
40
54
|
try {
|
|
41
55
|
stat = await fs.stat(filePath);
|
|
42
56
|
} catch {
|
|
43
|
-
return NextResponse.json({ error: `file not found: ${
|
|
57
|
+
return NextResponse.json({ error: `file not found: ${requestedPath}` }, { status: 404 });
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
const candidate: FileCandidate = {
|
package/next.config.mjs
CHANGED
|
@@ -16,17 +16,54 @@ const productionExperimentalConfig =
|
|
|
16
16
|
? { ...baseExperimental, serverMinification: false }
|
|
17
17
|
: baseExperimental;
|
|
18
18
|
|
|
19
|
+
// Defense-in-depth response headers for the local dashboard. The CSP keeps all
|
|
20
|
+
// resource loads same-origin (no third-party script/connect surface), and the
|
|
21
|
+
// frame protections block clickjacking of the unauthenticated UI. 'unsafe-inline'
|
|
22
|
+
// is required because Next.js injects inline bootstrap/hydration scripts and
|
|
23
|
+
// styles; everything else is locked to 'self'.
|
|
24
|
+
const securityHeaders = [
|
|
25
|
+
{ key: "X-Frame-Options", value: "DENY" },
|
|
26
|
+
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
27
|
+
{ key: "Referrer-Policy", value: "no-referrer" },
|
|
28
|
+
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
|
|
29
|
+
{
|
|
30
|
+
key: "Content-Security-Policy",
|
|
31
|
+
value: [
|
|
32
|
+
"default-src 'self'",
|
|
33
|
+
"script-src 'self' 'unsafe-inline'",
|
|
34
|
+
"style-src 'self' 'unsafe-inline'",
|
|
35
|
+
"img-src 'self' data: blob:",
|
|
36
|
+
"font-src 'self' data:",
|
|
37
|
+
"connect-src 'self'",
|
|
38
|
+
"object-src 'none'",
|
|
39
|
+
"base-uri 'self'",
|
|
40
|
+
"form-action 'self'",
|
|
41
|
+
"frame-ancestors 'none'"
|
|
42
|
+
].join("; ")
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
|
|
19
46
|
/** @type {import('next').NextConfig} */
|
|
20
47
|
const nextConfig = {
|
|
21
48
|
allowedDevOrigins: ["localhost", "127.0.0.1"],
|
|
22
49
|
devIndicators: false,
|
|
23
50
|
experimental: productionExperimentalConfig,
|
|
51
|
+
// The published package ships source and runs `next build` on the end user's
|
|
52
|
+
// machine at first `tokentrace serve`. Production installs omit devDependencies
|
|
53
|
+
// such as @types/better-sqlite3, so Next's build-time type check cannot pass
|
|
54
|
+
// there. Type safety is enforced in development and CI via `npm run verify`
|
|
55
|
+
// (`tsc --noEmit`); this only disables the redundant check during the
|
|
56
|
+
// user-machine build. Do not remove without making the user-side build
|
|
57
|
+
// type-check-free another way.
|
|
24
58
|
typescript: {
|
|
25
59
|
ignoreBuildErrors: true
|
|
26
60
|
},
|
|
27
61
|
serverExternalPackages: ["better-sqlite3"],
|
|
28
62
|
outputFileTracingRoot: projectRoot,
|
|
29
|
-
typedRoutes: false
|
|
63
|
+
typedRoutes: false,
|
|
64
|
+
async headers() {
|
|
65
|
+
return [{ source: "/:path*", headers: securityHeaders }];
|
|
66
|
+
}
|
|
30
67
|
};
|
|
31
68
|
|
|
32
69
|
// Bundle analyzer — install @next/bundle-analyzer as an optional devDep
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokentrace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"mcpName": "io.github.abhiyoheswaran1/tokentrace",
|
|
5
5
|
"description": "Local-first dashboard for AI CLI token, cost, and session analytics.",
|
|
6
6
|
"author": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"TOKENTRACE_AGENT.md",
|
|
25
25
|
"llms.txt",
|
|
26
26
|
"public",
|
|
27
|
+
"!public/Baseframe-Labs-brand-assets",
|
|
27
28
|
"app",
|
|
28
29
|
"components",
|
|
29
30
|
"docs/agent-adoption.md",
|
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"url": "https://github.com/abhiyoheswaran1/tokentrace",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.
|
|
11
|
+
"version": "0.19.1",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "tokentrace",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.19.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ServeOptions {
|
|
2
|
+
help: boolean;
|
|
3
|
+
hostname: string;
|
|
4
|
+
port: number | null;
|
|
5
|
+
openBrowser: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ResolvedServePort {
|
|
9
|
+
port: number;
|
|
10
|
+
fixed: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ServeContext {
|
|
14
|
+
appDataDir(): string;
|
|
15
|
+
nextBin(): string;
|
|
16
|
+
runtimeEnv(): NodeJS.ProcessEnv;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parsePort(value: unknown): number;
|
|
20
|
+
export function parseServeOptions(args: string[], env?: NodeJS.ProcessEnv): ServeOptions;
|
|
21
|
+
export function isLoopbackHostname(hostname: unknown): boolean;
|
|
22
|
+
export function assertHostnameAllowed(
|
|
23
|
+
hostname: string,
|
|
24
|
+
env?: Record<string, string | undefined>
|
|
25
|
+
): void;
|
|
26
|
+
export function startupProgress(step: string, detail?: string): void;
|
|
27
|
+
export function formatServeError(
|
|
28
|
+
error: unknown,
|
|
29
|
+
options?: { hostname?: string; port?: number | null }
|
|
30
|
+
): string;
|
|
31
|
+
export function resolveServePort(options: ServeOptions): Promise<ResolvedServePort>;
|
|
32
|
+
export function serve(context: ServeContext, args?: string[]): Promise<void>;
|
package/src/cli/serve.js
CHANGED
|
@@ -16,6 +16,28 @@ export function parsePort(value) {
|
|
|
16
16
|
return port;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
20
|
+
|
|
21
|
+
export function isLoopbackHostname(hostname) {
|
|
22
|
+
return LOOPBACK_HOSTNAMES.has(String(hostname ?? "").trim().toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The dashboard ships no authentication, so binding it to anything other than
|
|
27
|
+
* loopback exposes local AI usage data and the file-preview endpoints to the
|
|
28
|
+
* network. Refuse non-loopback binds unless the operator explicitly opts in.
|
|
29
|
+
*/
|
|
30
|
+
export function assertHostnameAllowed(hostname, env = process.env) {
|
|
31
|
+
if (isLoopbackHostname(hostname)) return;
|
|
32
|
+
if (env.TOKENTRACE_ALLOW_REMOTE === "1") return;
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Refusing to bind TokenTrace to non-loopback host "${hostname}". ` +
|
|
35
|
+
"The dashboard has no authentication, so this would expose your local AI usage data " +
|
|
36
|
+
"and file-preview endpoints to the network. " +
|
|
37
|
+
"Re-run with --hostname 127.0.0.1, or set TOKENTRACE_ALLOW_REMOTE=1 to override (not recommended)."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
export function parseServeOptions(args, env = process.env) {
|
|
20
42
|
const options = {
|
|
21
43
|
help: false,
|
|
@@ -119,6 +141,7 @@ export async function serve(context, args = []) {
|
|
|
119
141
|
let child = null;
|
|
120
142
|
|
|
121
143
|
try {
|
|
144
|
+
assertHostnameAllowed(hostname);
|
|
122
145
|
let resolvedPort = null;
|
|
123
146
|
if (options.port != null) {
|
|
124
147
|
resolvedPort = await resolveServePort(options);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { expandHome } from "@/src/ingestion/discovery";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Containment for endpoints that read a caller-supplied file path (parser
|
|
8
|
+
* previews, import-profile previews).
|
|
9
|
+
*
|
|
10
|
+
* The dashboard's perimeter (see request-guard) already blocks remote and
|
|
11
|
+
* cross-site callers, so the only legitimate caller is the user's own browser.
|
|
12
|
+
* This module is defense-in-depth: even a same-origin request may only read
|
|
13
|
+
* files under directories that could plausibly hold AI usage logs — the user's
|
|
14
|
+
* home directory, the OS temp directory, and any explicitly configured import
|
|
15
|
+
* folders. That keeps the feature usable (CLI logs live under $HOME) while
|
|
16
|
+
* ensuring the dashboard can never be turned into a reader for `/etc/*`,
|
|
17
|
+
* `/root/*`, other users' home directories, or files reached via symlink
|
|
18
|
+
* escapes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type PathAccessErrorCode = "invalid" | "not_found" | "forbidden";
|
|
22
|
+
|
|
23
|
+
export class PathAccessError extends Error {
|
|
24
|
+
readonly code: PathAccessErrorCode;
|
|
25
|
+
|
|
26
|
+
constructor(code: PathAccessErrorCode, message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "PathAccessError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** HTTP status that best matches each failure mode. */
|
|
34
|
+
export function pathAccessStatus(code: PathAccessErrorCode): number {
|
|
35
|
+
switch (code) {
|
|
36
|
+
case "invalid":
|
|
37
|
+
return 400;
|
|
38
|
+
case "not_found":
|
|
39
|
+
return 404;
|
|
40
|
+
case "forbidden":
|
|
41
|
+
return 403;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function realpathOrNull(target: string): Promise<string | null> {
|
|
46
|
+
try {
|
|
47
|
+
return await fs.realpath(target);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve and de-duplicate the directories the dashboard may read from. */
|
|
54
|
+
export async function getAllowedReadRoots(extraRoots: string[] = []): Promise<string[]> {
|
|
55
|
+
const base = [os.homedir(), os.tmpdir(), ...extraRoots.map((root) => expandHome(root.trim()))];
|
|
56
|
+
const resolved = await Promise.all(
|
|
57
|
+
base.filter(Boolean).map((root) => realpathOrNull(path.resolve(root)))
|
|
58
|
+
);
|
|
59
|
+
return Array.from(new Set(resolved.filter((root): root is string => Boolean(root))));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isWithinRoot(target: string, root: string): boolean {
|
|
63
|
+
if (target === root) return true;
|
|
64
|
+
const rel = path.relative(root, target);
|
|
65
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate a caller-supplied path and return its fully resolved (symlink-free)
|
|
70
|
+
* form. Throws {@link PathAccessError} with a `code` that maps to an HTTP status
|
|
71
|
+
* via {@link pathAccessStatus}.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveReadablePath(
|
|
74
|
+
input: unknown,
|
|
75
|
+
options: { extraRoots?: string[] } = {}
|
|
76
|
+
): Promise<string> {
|
|
77
|
+
const expanded = typeof input === "string" ? expandHome(input.trim()) : "";
|
|
78
|
+
if (!expanded) {
|
|
79
|
+
throw new PathAccessError("invalid", "A file path is required.");
|
|
80
|
+
}
|
|
81
|
+
if (!path.isAbsolute(expanded)) {
|
|
82
|
+
throw new PathAccessError("invalid", "File path must be absolute.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const real = await realpathOrNull(expanded);
|
|
86
|
+
if (!real) {
|
|
87
|
+
throw new PathAccessError("not_found", `File not found: ${expanded}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const roots = await getAllowedReadRoots(options.extraRoots);
|
|
91
|
+
if (!roots.some((root) => isWithinRoot(real, root))) {
|
|
92
|
+
throw new PathAccessError(
|
|
93
|
+
"forbidden",
|
|
94
|
+
"Path is outside the allowed import directories (home, temp, and configured folders)."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return real;
|
|
99
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perimeter guard for the local dashboard's HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard is unauthenticated by design (local-first, single user), so it
|
|
5
|
+
* relies entirely on *who can reach it* and *who is allowed to drive it*:
|
|
6
|
+
*
|
|
7
|
+
* - Host allowlist: rejects requests whose `Host` header is not a loopback
|
|
8
|
+
* name. This defeats DNS-rebinding attacks, where a malicious page rebinds
|
|
9
|
+
* its own domain to 127.0.0.1 to turn cross-origin reads into "same-origin"
|
|
10
|
+
* ones. A rebound request still carries the attacker's `Host`.
|
|
11
|
+
* - Cross-site write protection: rejects state-changing requests that a
|
|
12
|
+
* browser reports as cross-site (or that carry a non-loopback `Origin`).
|
|
13
|
+
* This defeats CSRF, where a malicious page silently POSTs to the dashboard.
|
|
14
|
+
*
|
|
15
|
+
* Non-browser clients (CLI, curl) send neither `Origin` nor `Sec-Fetch-*` and
|
|
16
|
+
* are allowed through — the threat model is a browser being weaponised by a
|
|
17
|
+
* third-party site, not the user's own tooling.
|
|
18
|
+
*
|
|
19
|
+
* This module is intentionally dependency-free so it can run in the Next.js
|
|
20
|
+
* middleware (edge) runtime and be unit-tested in isolation.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type RequestGuardInput = {
|
|
24
|
+
method: string;
|
|
25
|
+
host: string | null | undefined;
|
|
26
|
+
origin?: string | null;
|
|
27
|
+
secFetchSite?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type RequestGuardEnv = {
|
|
31
|
+
TOKENTRACE_ALLOW_REMOTE?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type RequestGuardResult =
|
|
35
|
+
| { ok: true }
|
|
36
|
+
| { ok: false; status: number; error: string };
|
|
37
|
+
|
|
38
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
39
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
40
|
+
|
|
41
|
+
/** Extract the lowercased hostname from a `Host`/authority value, dropping any port. */
|
|
42
|
+
function hostnameOf(value: string): string {
|
|
43
|
+
const trimmed = value.trim().toLowerCase();
|
|
44
|
+
if (!trimmed) return "";
|
|
45
|
+
// Bracketed IPv6 literal, optionally with a port: [::1]:3030
|
|
46
|
+
if (trimmed.startsWith("[")) {
|
|
47
|
+
const end = trimmed.indexOf("]");
|
|
48
|
+
return end === -1 ? trimmed : trimmed.slice(0, end + 1);
|
|
49
|
+
}
|
|
50
|
+
// Bare IPv6 (multiple colons) has no port suffix we can strip safely.
|
|
51
|
+
if (trimmed.split(":").length > 2) return trimmed;
|
|
52
|
+
const colon = trimmed.indexOf(":");
|
|
53
|
+
const host = colon === -1 ? trimmed : trimmed.slice(0, colon);
|
|
54
|
+
// Treat the FQDN trailing-dot form ("localhost.") as the bare name. This only
|
|
55
|
+
// ever loosens matching toward the canonical name, never across it.
|
|
56
|
+
return host.endsWith(".") ? host.slice(0, -1) : host;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLoopbackAuthority(value: string | null | undefined): boolean {
|
|
60
|
+
if (!value) return false;
|
|
61
|
+
return LOOPBACK_HOSTNAMES.has(hostnameOf(value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function originIsLoopback(origin: string | null | undefined): boolean {
|
|
65
|
+
if (!origin) return false;
|
|
66
|
+
try {
|
|
67
|
+
return LOOPBACK_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function evaluateRequestGuard(
|
|
74
|
+
input: RequestGuardInput,
|
|
75
|
+
env: RequestGuardEnv = {}
|
|
76
|
+
): RequestGuardResult {
|
|
77
|
+
const allowRemote = env.TOKENTRACE_ALLOW_REMOTE === "1";
|
|
78
|
+
|
|
79
|
+
if (!allowRemote && !isLoopbackAuthority(input.host)) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
status: 421,
|
|
83
|
+
error:
|
|
84
|
+
"Request Host is not a recognised local address. TokenTrace only serves loopback hosts unless TOKENTRACE_ALLOW_REMOTE=1."
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const method = input.method.toUpperCase();
|
|
89
|
+
if (SAFE_METHODS.has(method)) {
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (allowRemote) {
|
|
94
|
+
return { ok: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const secFetchSite = input.secFetchSite?.toLowerCase();
|
|
98
|
+
if (secFetchSite) {
|
|
99
|
+
if (secFetchSite === "same-origin" || secFetchSite === "none") {
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
status: 403,
|
|
105
|
+
error: "Cross-site request blocked. TokenTrace only accepts same-origin writes."
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// No Sec-Fetch-Site (older browser or non-browser client): fall back to Origin.
|
|
110
|
+
if (input.origin && !originIsLoopback(input.origin)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 403,
|
|
114
|
+
error: "Cross-origin request blocked. TokenTrace only accepts same-origin writes."
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { ok: true };
|
|
119
|
+
}
|