@gajae-code/ai 0.18.8 → 0.18.10
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 +26 -0
- package/dist/types/utils/parse-bind.d.ts +6 -3
- package/package.json +3 -3
- package/src/auth-broker/client.ts +1 -0
- package/src/auth-gateway/server.ts +17 -2
- package/src/auth-storage.ts +3 -1
- package/src/model-cache.ts +1 -1
- package/src/provider-models/openai-compat.ts +3 -1
- package/src/providers/anthropic.ts +83 -11
- package/src/providers/openai-codex-responses.ts +6 -0
- package/src/utils/http-inspector.ts +12 -2
- package/src/utils/parse-bind.ts +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.18.10] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
## [0.18.9] - 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Exclude Claude Code and OAuth beta headers from API-key requests so subscription-provided API credits can be used, while preserving OAuth headers and feature betas.
|
|
12
|
+
|
|
13
|
+
- Anthropic API-key requests no longer send the Claude Code `X-App: cli` marker, and Anthropic API-key model discovery (`/v1/models`) no longer sends the Claude Code beta list (`claude-code-20250219`, `oauth-2025-04-20`, …). Anthropic classified requests carrying these markers as Claude Code usage, which excluded them from API credit grants such as the Max plan's monthly API credit. OAuth requests are unchanged.
|
|
14
|
+
|
|
15
|
+
- Finalize all outstanding SQLite auth statements on close, including usage-fetch leases, so Windows releases database file handles immediately.
|
|
16
|
+
|
|
17
|
+
- Auth broker credential requests do not follow redirects, so a 307 or 308 cannot replay the JSON body to another host.
|
|
18
|
+
|
|
19
|
+
- Preserve bounded terminal errors when Codex Responses times out mid-tool-call by dropping unvalidated partial arguments, retaining the call identity, and reporting `request_timeout`.
|
|
20
|
+
|
|
21
|
+
- A project `.env` can no longer add Foundry request headers through `ANTHROPIC_CUSTOM_HEADERS`.
|
|
22
|
+
|
|
23
|
+
- A project `.env` can no longer choose the Foundry CA or mTLS client certificate, including when the declaration uses `$` or backticks, and later deleting that file or changing directory does not turn the loaded value into an operator setting.
|
|
24
|
+
|
|
25
|
+
- A tokenless auth gateway rejects data requests whose Host header is not the loopback bind.
|
|
26
|
+
|
|
27
|
+
- HTTP 400 request dumps redact `x-goog-api-key` and `cf-aig-authorization` before the dump JSON is written, and the dump file is created owner-readable only.
|
|
28
|
+
|
|
29
|
+
- Finalize cached SQLite statements when closing the shared model cache so Windows releases its database file handle before temporary roots are removed.
|
|
30
|
+
|
|
5
31
|
## [0.18.8] - 2026-10-08
|
|
6
32
|
|
|
7
33
|
### Added
|
|
@@ -25,8 +25,11 @@ export declare function isLoopbackHostname(hostname: string): boolean;
|
|
|
25
25
|
*/
|
|
26
26
|
export declare function assertAuthenticatedOrLoopback(bind: ParsedBind, bearerTokenCount: number, serverName: string): void;
|
|
27
27
|
/**
|
|
28
|
-
* Tokenless loopback
|
|
29
|
-
* bound hostname and port.
|
|
30
|
-
*
|
|
28
|
+
* Tokenless loopback servers accept a request only when its Host header is the
|
|
29
|
+
* bound hostname and effective port. Default-port forms that stay allowed:
|
|
30
|
+
* a Host with no port, or an explicit :80, on an HTTP listener on port 80;
|
|
31
|
+
* an explicit :443 on a listener on port 443. Any other omitted port is
|
|
32
|
+
* rejected. A DNS-rebound same-origin GET sends the attacker's name and no
|
|
33
|
+
* Origin header.
|
|
31
34
|
*/
|
|
32
35
|
export declare function hostHeaderMatchesBind(hostHeader: string | null, bind: ParsedBind): boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/ai",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.10",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
42
42
|
"@anthropic-ai/sdk": "^0.94.0",
|
|
43
43
|
"@bufbuild/protobuf": "^2.12.0",
|
|
44
|
-
"@gajae-code/natives": "0.18.
|
|
45
|
-
"@gajae-code/utils": "0.18.
|
|
44
|
+
"@gajae-code/natives": "0.18.10",
|
|
45
|
+
"@gajae-code/utils": "0.18.10",
|
|
46
46
|
"openai": "^6.36.0",
|
|
47
47
|
"partial-json": "^0.1.7",
|
|
48
48
|
"zod": "4.4.3"
|
|
@@ -39,7 +39,7 @@ import type {
|
|
|
39
39
|
SimpleStreamOptions,
|
|
40
40
|
} from "../types";
|
|
41
41
|
import { beginAttempt, classifyFallbackTrigger } from "../utils/fallback-transport";
|
|
42
|
-
import { assertAuthenticatedOrLoopback, parseBind } from "../utils/parse-bind";
|
|
42
|
+
import { assertAuthenticatedOrLoopback, hostHeaderMatchesBind, parseBind } from "../utils/parse-bind";
|
|
43
43
|
import { REPETITION_GUARD_ERROR_CODE } from "../utils/stream-repetition-guard";
|
|
44
44
|
import {
|
|
45
45
|
captureRequestHeaders,
|
|
@@ -1281,6 +1281,7 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
1281
1281
|
assertAuthenticatedOrLoopback(bind, tokens.size, "auth-gateway");
|
|
1282
1282
|
const version = opts.version;
|
|
1283
1283
|
|
|
1284
|
+
let listenPort = bind.port;
|
|
1284
1285
|
const server = Bun.serve({
|
|
1285
1286
|
hostname: bind.hostname,
|
|
1286
1287
|
port: bind.port,
|
|
@@ -1297,6 +1298,19 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
1297
1298
|
});
|
|
1298
1299
|
return json(403, { error: "no-auth rejects requests carrying Origin" });
|
|
1299
1300
|
}
|
|
1301
|
+
if (
|
|
1302
|
+
tokens.size === 0 &&
|
|
1303
|
+
!(req.method === "GET" && pathname === "/healthz") &&
|
|
1304
|
+
!hostHeaderMatchesBind(req.headers.get("host"), { hostname: bind.hostname, port: listenPort })
|
|
1305
|
+
) {
|
|
1306
|
+
logger.info("auth-gateway no-auth host rejected", {
|
|
1307
|
+
method: req.method,
|
|
1308
|
+
path: pathname,
|
|
1309
|
+
peer,
|
|
1310
|
+
hostPresent: req.headers.has("host"),
|
|
1311
|
+
});
|
|
1312
|
+
return json(403, { error: "no-auth rejects a host that is not the loopback bind" });
|
|
1313
|
+
}
|
|
1300
1314
|
// CORS preflight is always answered without auth — browsers send
|
|
1301
1315
|
// preflights pre-authentication and a 401 here breaks the actual
|
|
1302
1316
|
// request before the bearer is ever attached.
|
|
@@ -1391,8 +1405,9 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
|
|
|
1391
1405
|
idleTimeout: 255,
|
|
1392
1406
|
});
|
|
1393
1407
|
|
|
1408
|
+
listenPort = server.port ?? listenPort;
|
|
1394
1409
|
const boundHost = server.hostname ?? bind.hostname;
|
|
1395
|
-
const boundPort = server.port ??
|
|
1410
|
+
const boundPort = server.port ?? listenPort;
|
|
1396
1411
|
return {
|
|
1397
1412
|
url: `http://${boundHost}:${boundPort}`,
|
|
1398
1413
|
port: boundPort,
|
package/src/auth-storage.ts
CHANGED
|
@@ -8281,8 +8281,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
8281
8281
|
this.#getCacheStmt.finalize();
|
|
8282
8282
|
this.#getCacheIncludingExpiredStmt.finalize();
|
|
8283
8283
|
this.#upsertCacheStmt.finalize();
|
|
8284
|
+
this.#claimUsageFetchLeaseStmt.finalize();
|
|
8285
|
+
this.#releaseUsageFetchLeaseStmt.finalize();
|
|
8284
8286
|
this.#deleteCachePrefixStmt.finalize();
|
|
8285
8287
|
this.#deleteExpiredCacheStmt.finalize();
|
|
8286
|
-
this.#db.close();
|
|
8288
|
+
this.#db.close(true);
|
|
8287
8289
|
}
|
|
8288
8290
|
}
|
package/src/model-cache.ts
CHANGED
|
@@ -77,7 +77,7 @@ function getDb(dbPath?: string): Database {
|
|
|
77
77
|
export function closeModelCache(dbPath?: string): boolean {
|
|
78
78
|
const resolvedPath = dbPath ?? getModelDbPath();
|
|
79
79
|
if (!sharedDb || sharedDbPath !== resolvedPath) return false;
|
|
80
|
-
sharedDb.close();
|
|
80
|
+
sharedDb.close(true);
|
|
81
81
|
sharedDb = null;
|
|
82
82
|
sharedDbPath = null;
|
|
83
83
|
return true;
|
|
@@ -148,9 +148,11 @@ function buildAnthropicDiscoveryHeaders(apiKey: string): Record<string, string>
|
|
|
148
148
|
const headers: Record<string, string> = {
|
|
149
149
|
"anthropic-version": "2023-06-01",
|
|
150
150
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
151
|
-
"anthropic-beta": ANTHROPIC_OAUTH_BETA,
|
|
152
151
|
};
|
|
153
152
|
if (oauthToken) {
|
|
153
|
+
// Claude Code identity betas are OAuth-only; API-key requests carrying them are
|
|
154
|
+
// classified as Claude Code usage and excluded from API credit grants.
|
|
155
|
+
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
|
|
154
156
|
headers.Authorization = `Bearer ${apiKey}`;
|
|
155
157
|
} else {
|
|
156
158
|
headers["x-api-key"] = apiKey;
|
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
isUnexpectedSocketCloseMessage,
|
|
20
20
|
logger,
|
|
21
21
|
readSseEvents,
|
|
22
|
+
startupProjectEnvSnapshot,
|
|
22
23
|
} from "@gajae-code/utils";
|
|
24
|
+
import { canonicalEnvKey, type ProjectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
23
25
|
import {
|
|
24
26
|
anthropicProviderDiagnosticFromError,
|
|
25
27
|
anthropicProviderDiagnosticFromSseErrorData,
|
|
@@ -167,12 +169,8 @@ export function buildBetaHeader(baseBetas: string[], extraBetas: string[]): stri
|
|
|
167
169
|
return result.join(",");
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
"oauth-2025-04-20",
|
|
173
|
-
"context-management-2025-06-27",
|
|
174
|
-
"prompt-caching-scope-2026-01-05",
|
|
175
|
-
];
|
|
172
|
+
const featureBetaDefaults = ["context-management-2025-06-27", "prompt-caching-scope-2026-01-05"];
|
|
173
|
+
const claudeCodeBetaDefaults = ["claude-code-20250219", "oauth-2025-04-20", ...featureBetaDefaults];
|
|
176
174
|
const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14";
|
|
177
175
|
const interleavedThinkingBeta = "interleaved-thinking-2025-05-14";
|
|
178
176
|
const fastModeBeta = "fast-mode-2026-02-01";
|
|
@@ -207,6 +205,12 @@ const sharedHeaders = {
|
|
|
207
205
|
"Content-Type": "application/json",
|
|
208
206
|
"Anthropic-Version": "2023-06-01",
|
|
209
207
|
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Claude Code client marker. Like the claude-code/oauth betas it is sent only with
|
|
211
|
+
// OAuth credentials: Anthropic classifies API-key requests carrying it as Claude
|
|
212
|
+
// Code usage, which excludes them from API credit grants.
|
|
213
|
+
const claudeCodeAppHeaders = {
|
|
210
214
|
"X-App": "cli",
|
|
211
215
|
};
|
|
212
216
|
|
|
@@ -275,7 +279,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
|
|
275
279
|
const oauthToken = options.isOAuth ?? isAnthropicOAuthToken(options.apiKey);
|
|
276
280
|
const extraBetas = options.extraBetas ?? [];
|
|
277
281
|
const stream = options.stream ?? false;
|
|
278
|
-
const betaHeader = buildBetaHeader(claudeCodeBetaDefaults, extraBetas);
|
|
282
|
+
const betaHeader = buildBetaHeader(oauthToken ? claudeCodeBetaDefaults : featureBetaDefaults, extraBetas);
|
|
279
283
|
const acceptHeader = stream ? "text/event-stream" : "application/json";
|
|
280
284
|
const modelHeaders = Object.fromEntries(
|
|
281
285
|
Object.entries(options.modelHeaders ?? {}).filter(([key]) => !enforcedHeaderKeys.has(key.toLowerCase())),
|
|
@@ -302,6 +306,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
|
|
302
306
|
Accept: acceptHeader,
|
|
303
307
|
Authorization: `Bearer ${options.apiKey}`,
|
|
304
308
|
...sharedHeaders,
|
|
309
|
+
...claudeCodeAppHeaders,
|
|
305
310
|
"Anthropic-Beta": betaHeader,
|
|
306
311
|
"User-Agent": userAgent,
|
|
307
312
|
};
|
|
@@ -1241,13 +1246,76 @@ function parseAnthropicCustomHeaders(rawHeaders: string | undefined): Record<str
|
|
|
1241
1246
|
function resolveAnthropicCustomHeaders(model: Model<"anthropic-messages">): Record<string, string> | undefined {
|
|
1242
1247
|
if (model.provider !== "anthropic") return undefined;
|
|
1243
1248
|
if (!isFoundryEnabled()) return undefined;
|
|
1244
|
-
return parseAnthropicCustomHeaders(
|
|
1249
|
+
return parseAnthropicCustomHeaders(
|
|
1250
|
+
trustedFoundryHeaderValue("ANTHROPIC_CUSTOM_HEADERS", startupProjectEnvSnapshot()),
|
|
1251
|
+
);
|
|
1245
1252
|
}
|
|
1246
1253
|
|
|
1247
1254
|
function looksLikeFilePath(value: string): boolean {
|
|
1248
1255
|
return value.includes("/") || value.includes("\\") || /\.(pem|crt|cer|key)$/i.test(value);
|
|
1249
1256
|
}
|
|
1250
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* Bun turns `\n` and `\r` inside double-quoted dotenv values into control
|
|
1260
|
+
* characters before they reach `process.env`. The project snapshot keeps the
|
|
1261
|
+
* source text, so a literal compare would miss that rewrite.
|
|
1262
|
+
*/
|
|
1263
|
+
function unescapeProjectDotenvControls(value: string): string {
|
|
1264
|
+
let decoded = "";
|
|
1265
|
+
for (let index = 0; index < value.length; index++) {
|
|
1266
|
+
const current = value[index];
|
|
1267
|
+
const next = value[index + 1];
|
|
1268
|
+
if (current === "\\" && next === "n") {
|
|
1269
|
+
decoded += "\n";
|
|
1270
|
+
index++;
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (current === "\\" && next === "r") {
|
|
1274
|
+
decoded += "\r";
|
|
1275
|
+
index++;
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
decoded += current ?? "";
|
|
1279
|
+
}
|
|
1280
|
+
return decoded;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Operator environment only. `$env` already contains the project dotenv, and a
|
|
1285
|
+
* value equal to that declaration — or produced from a `$` / backtick
|
|
1286
|
+
* declaration — must not become the Foundry trust anchor or client identity.
|
|
1287
|
+
*/
|
|
1288
|
+
function trustedFoundryTlsValue(name: string, snapshot: ProjectEnvSnapshot): string | undefined {
|
|
1289
|
+
const raw = $env[name];
|
|
1290
|
+
if (!raw) return undefined;
|
|
1291
|
+
const key = canonicalEnvKey(name);
|
|
1292
|
+
const declared = snapshot.values[key];
|
|
1293
|
+
if (
|
|
1294
|
+
declared !== undefined &&
|
|
1295
|
+
(snapshot.dynamic.has(key) || declared === raw || unescapeProjectDotenvControls(declared) === raw)
|
|
1296
|
+
) {
|
|
1297
|
+
return undefined;
|
|
1298
|
+
}
|
|
1299
|
+
return raw;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Operator environment only, for Foundry request headers.
|
|
1304
|
+
*
|
|
1305
|
+
* A `$` / backtick declaration, or a value equal to the project declaration,
|
|
1306
|
+
* is ignored. Literal `\n` in that declaration is not decoded: Bun decodes it
|
|
1307
|
+
* only inside double quotes, and decoding every declaration would also drop
|
|
1308
|
+
* an operator value that is a real newline.
|
|
1309
|
+
*/
|
|
1310
|
+
function trustedFoundryHeaderValue(name: string, snapshot: ProjectEnvSnapshot): string | undefined {
|
|
1311
|
+
const raw = $env[name];
|
|
1312
|
+
if (!raw) return undefined;
|
|
1313
|
+
const key = canonicalEnvKey(name);
|
|
1314
|
+
const declared = snapshot.values[key];
|
|
1315
|
+
if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw)) return undefined;
|
|
1316
|
+
return raw;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1251
1319
|
function resolvePemValue(value: string | undefined, name: string): string | undefined {
|
|
1252
1320
|
const trimmed = value?.trim();
|
|
1253
1321
|
if (!trimmed) return undefined;
|
|
@@ -1275,9 +1343,13 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl
|
|
|
1275
1343
|
if (model.provider !== "anthropic") return undefined;
|
|
1276
1344
|
if (!isFoundryEnabled()) return undefined;
|
|
1277
1345
|
|
|
1278
|
-
const
|
|
1279
|
-
const
|
|
1280
|
-
const
|
|
1346
|
+
const projectEnv = startupProjectEnvSnapshot();
|
|
1347
|
+
const ca = resolvePemValue(trustedFoundryTlsValue("NODE_EXTRA_CA_CERTS", projectEnv), "NODE_EXTRA_CA_CERTS");
|
|
1348
|
+
const cert = resolvePemValue(
|
|
1349
|
+
trustedFoundryTlsValue("CLAUDE_CODE_CLIENT_CERT", projectEnv),
|
|
1350
|
+
"CLAUDE_CODE_CLIENT_CERT",
|
|
1351
|
+
);
|
|
1352
|
+
const key = resolvePemValue(trustedFoundryTlsValue("CLAUDE_CODE_CLIENT_KEY", projectEnv), "CLAUDE_CODE_CLIENT_KEY");
|
|
1281
1353
|
|
|
1282
1354
|
if ((cert && !key) || (!cert && key)) {
|
|
1283
1355
|
throw new Error("Both CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY must be set for mTLS.");
|
|
@@ -2779,6 +2779,11 @@ async function handleCodexStreamFailure(
|
|
|
2779
2779
|
error: unknown,
|
|
2780
2780
|
): Promise<AssistantMessage> {
|
|
2781
2781
|
const { output } = context;
|
|
2782
|
+
// Preserve failed-call identities for diagnostics, but not their unvalidated
|
|
2783
|
+
// arguments: a stalled call can otherwise make the terminal error oversized.
|
|
2784
|
+
for (const block of output.content) {
|
|
2785
|
+
if (block.type === "toolCall" && "partialJson" in block) block.arguments = {};
|
|
2786
|
+
}
|
|
2782
2787
|
removeTransientBlockIndices(output);
|
|
2783
2788
|
if (context.requestContext.websocketState) {
|
|
2784
2789
|
resetCodexWebSocketAppendState(context.requestContext.websocketState);
|
|
@@ -2794,6 +2799,7 @@ async function handleCodexStreamFailure(
|
|
|
2794
2799
|
codexError !== undefined && CODEX_TYPED_TRANSPORT_PROVIDER_CODES.has(codexError.code ?? "")
|
|
2795
2800
|
? codexError.code
|
|
2796
2801
|
: undefined;
|
|
2802
|
+
if (codexError?.code === "request_timeout") output.errorCode = codexError.code;
|
|
2797
2803
|
output.transportFailure = typedProviderCode
|
|
2798
2804
|
? explicitTerminalVeto
|
|
2799
2805
|
? undefined
|
|
@@ -26,7 +26,16 @@ type ErrorWithStatus = {
|
|
|
26
26
|
status?: unknown;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
const SENSITIVE_HEADERS = [
|
|
29
|
+
const SENSITIVE_HEADERS = [
|
|
30
|
+
"authorization",
|
|
31
|
+
"x-api-key",
|
|
32
|
+
"api-key",
|
|
33
|
+
"cookie",
|
|
34
|
+
"set-cookie",
|
|
35
|
+
"proxy-authorization",
|
|
36
|
+
"x-goog-api-key",
|
|
37
|
+
"cf-aig-authorization",
|
|
38
|
+
];
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* Connection-level failure codes, meaning the request never reached the
|
|
@@ -162,7 +171,8 @@ export async function appendRawHttpRequestDumpFor400(
|
|
|
162
171
|
const filePath = path.join(dumpDir, fileName);
|
|
163
172
|
|
|
164
173
|
try {
|
|
165
|
-
await
|
|
174
|
+
await fs.mkdir(dumpDir, { recursive: true });
|
|
175
|
+
await fs.writeFile(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`, { mode: 0o600 });
|
|
166
176
|
await pruneHttpRequestDumps(dumpDir);
|
|
167
177
|
return `${message}\nraw-http-request=${filePath}\n${RAW_HTTP_REQUEST_PRIVACY_NOTE}`;
|
|
168
178
|
} catch (writeError) {
|
package/src/utils/parse-bind.ts
CHANGED
|
@@ -87,18 +87,20 @@ function parseHostPort(raw: string): number | null {
|
|
|
87
87
|
return port;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/** Split an HTTP Host header into a hostname and an explicit port. */
|
|
91
|
-
function parseHostHeader(value: string): { hostname: string; port: number } | null {
|
|
90
|
+
/** Split an HTTP Host header into a hostname and an explicit port, if present. */
|
|
91
|
+
function parseHostHeader(value: string): { hostname: string; port: number | null } | null {
|
|
92
92
|
if (value.startsWith("[")) {
|
|
93
93
|
const end = value.indexOf("]");
|
|
94
94
|
if (end <= 1) return null;
|
|
95
95
|
const rest = value.slice(end + 1);
|
|
96
|
+
if (rest.length === 0) return { hostname: value.slice(1, end), port: null };
|
|
96
97
|
if (!rest.startsWith(":")) return null;
|
|
97
98
|
const port = parseHostPort(rest.slice(1));
|
|
98
99
|
if (port === null) return null;
|
|
99
100
|
return { hostname: value.slice(1, end), port };
|
|
100
101
|
}
|
|
101
102
|
const colon = value.lastIndexOf(":");
|
|
103
|
+
if (colon === -1) return { hostname: value, port: null };
|
|
102
104
|
if (colon <= 0 || value.indexOf(":") !== colon) return null;
|
|
103
105
|
const port = parseHostPort(value.slice(colon + 1));
|
|
104
106
|
if (port === null) return null;
|
|
@@ -108,19 +110,24 @@ function parseHostHeader(value: string): { hostname: string; port: number } | nu
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/**
|
|
111
|
-
* Tokenless loopback
|
|
112
|
-
* bound hostname and port.
|
|
113
|
-
*
|
|
113
|
+
* Tokenless loopback servers accept a request only when its Host header is the
|
|
114
|
+
* bound hostname and effective port. Default-port forms that stay allowed:
|
|
115
|
+
* a Host with no port, or an explicit :80, on an HTTP listener on port 80;
|
|
116
|
+
* an explicit :443 on a listener on port 443. Any other omitted port is
|
|
117
|
+
* rejected. A DNS-rebound same-origin GET sends the attacker's name and no
|
|
118
|
+
* Origin header.
|
|
114
119
|
*/
|
|
115
120
|
export function hostHeaderMatchesBind(hostHeader: string | null, bind: ParsedBind): boolean {
|
|
116
121
|
if (hostHeader === null) return false;
|
|
117
122
|
const trimmed = hostHeader.trim();
|
|
118
123
|
if (trimmed.length === 0 || /[\s@]/.test(trimmed)) return false;
|
|
119
124
|
const parsed = parseHostHeader(trimmed);
|
|
120
|
-
if (!parsed) return false;
|
|
125
|
+
if (!parsed || parsed.hostname.length === 0) return false;
|
|
126
|
+
const port = parsed.port ?? (bind.port === 80 ? 80 : null);
|
|
127
|
+
if (port === null) return false;
|
|
121
128
|
const bindHost = bind.hostname
|
|
122
129
|
.trim()
|
|
123
130
|
.toLowerCase()
|
|
124
131
|
.replace(/^\[|\]$/g, "");
|
|
125
|
-
return parsed.hostname.toLowerCase() === bindHost &&
|
|
132
|
+
return parsed.hostname.toLowerCase() === bindHost && port === bind.port;
|
|
126
133
|
}
|