tokentrace 0.18.1 → 0.19.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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to TokenTrace are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.19.0] - 2026-06-04
8
+
9
+ ### Security
10
+
11
+ - **The local dashboard now enforces a request perimeter.** All `/api/*` routes
12
+ go through `middleware.ts`, which rejects requests whose `Host` is not a
13
+ loopback name (defeating DNS-rebinding that would otherwise let a malicious
14
+ web page read your local data as "same-origin") and blocks cross-site
15
+ state-changing requests (defeating CSRF that could silently re-point the
16
+ scanner, change settings, or wipe imported data). Non-browser clients (CLI,
17
+ curl) are unaffected.
18
+ - **File-preview endpoints are now contained.** `/api/parser-debug/preview` and
19
+ `/api/import-profile-preview` previously read any caller-supplied path. They
20
+ now resolve symlinks and only read files under your home directory, the OS
21
+ temp directory, or explicitly configured import folders, returning `403` for
22
+ anything else. This removes an arbitrary-file-read surface.
23
+ - **`tokentrace serve` refuses non-loopback binds by default.** Binding to
24
+ `0.0.0.0` or a LAN address (via `--hostname` or `TOKENTRACE_HOSTNAME`) now
25
+ errors with guidance, since the dashboard is unauthenticated. Set
26
+ `TOKENTRACE_ALLOW_REMOTE=1` to override deliberately.
27
+ - **Security response headers added.** The dashboard now sends a strict
28
+ same-origin `Content-Security-Policy`, `X-Frame-Options: DENY`,
29
+ `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and a
30
+ restrictive `Permissions-Policy` (anti-clickjacking and anti-sniffing).
31
+
7
32
  ## [0.18.1] - 2026-05-28
8
33
 
9
34
  ### 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: filePath.trim(),
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 filePath = typeof body.path === "string" ? body.path.trim() : "";
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 (!filePath) {
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: ${filePath}` }, { status: 404 });
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.18.1",
3
+ "version": "0.19.0",
4
4
  "mcpName": "io.github.abhiyoheswaran1/tokentrace",
5
5
  "description": "Local-first dashboard for AI CLI token, cost, and session analytics.",
6
6
  "author": {
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.18.1",
11
+ "version": "0.19.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "tokentrace",
16
- "version": "0.18.1",
16
+ "version": "0.19.0",
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
+ }