Files
jhonny-editor/code/src/http-admission.ts
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

403 lines
15 KiB
TypeScript
Executable File

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type http from "node:http";
export const MCP_HTTP_METHODS = ["GET", "POST", "DELETE"] as const;
export interface HttpAuthConfiguration {
mode: "shared-token" | "oauth" | "unauthenticated";
authToken?: string;
oauth?: {
issuer: string;
audience: string;
publicUrl: string;
jwksUri: string;
requiredScopes: string[];
allowedSubjects: string[];
};
allowUnauthenticated: boolean;
}
export interface HttpAdmissionSettings {
maxRequestBytes: number;
headersTimeoutMs: number;
requestTimeoutMs: number;
keepAliveTimeoutMs: number;
maxRequestsPerSocket: number;
maxConcurrentRequests: number;
maxConcurrentStreams: number;
rateLimitPerMinute: number;
rateLimitBurst: number;
maxRateLimitKeys: number;
trustProxy: boolean;
}
export interface AdmissionMetrics {
activeRequests: number;
activeOperationRequests: number;
activeStreamRequests: number;
trackedRateLimitKeys: number;
}
export type AdmissionLane = "operation" | "stream";
export type AdmissionDecision =
| { accepted: true; release: () => void }
| { accepted: false; reason: "rate_limited" | "at_capacity"; statusCode: 429 | 503; retryAfterSeconds: number };
const ONE_MINUTE_MS = 60_000;
const RATE_LIMIT_IDENTITY_KEY = randomBytes(32);
function readBoundedInteger(
env: NodeJS.ProcessEnv,
name: string,
fallback: number,
minimum: number,
maximum: number,
): number {
const raw = env[name];
if (raw === undefined || raw === "") return fallback;
if (!/^\d+$/.test(raw)) {
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
}
return value;
}
/**
* Reads the public-HTTP containment settings. Invalid values fail startup so a
* typo cannot silently turn a request or socket bound into an unlimited one.
*/
export function readHttpAdmissionSettings(env: NodeJS.ProcessEnv): HttpAdmissionSettings {
const rateLimitPerMinute = readBoundedInteger(env, "MCP_RATE_LIMIT_PER_MINUTE", 120, 1, 10_000);
const rateLimitBurst = readBoundedInteger(env, "MCP_RATE_LIMIT_BURST", 30, 1, rateLimitPerMinute);
return {
maxRequestBytes: readBoundedInteger(env, "MCP_MAX_REQUEST_BYTES", 1_048_576, 1_024, 10_485_760),
headersTimeoutMs: readBoundedInteger(env, "MCP_HEADERS_TIMEOUT_MS", 10_000, 1_000, 60_000),
requestTimeoutMs: readBoundedInteger(env, "MCP_REQUEST_TIMEOUT_MS", 60_000, 1_000, 300_000),
keepAliveTimeoutMs: readBoundedInteger(env, "MCP_KEEP_ALIVE_TIMEOUT_MS", 5_000, 1_000, 60_000),
maxRequestsPerSocket: readBoundedInteger(env, "MCP_MAX_REQUESTS_PER_SOCKET", 100, 1, 10_000),
maxConcurrentRequests: readBoundedInteger(env, "MCP_MAX_CONCURRENT_REQUESTS", 8, 1, 128),
maxConcurrentStreams: readBoundedInteger(env, "MCP_MAX_CONCURRENT_STREAMS", 32, 1, 1_024),
rateLimitPerMinute,
rateLimitBurst,
maxRateLimitKeys: readBoundedInteger(env, "MCP_MAX_RATE_LIMIT_KEYS", 2_048, 16, 100_000),
trustProxy: env.MCP_TRUST_PROXY === "1",
};
}
/**
* A network-reachable editor control plane must never start unauthenticated in
* production. The override remains available only for local development and
* test harnesses where it does not create a public deployment.
*/
export function readHttpAuthConfiguration(env: NodeJS.ProcessEnv): HttpAuthConfiguration {
const authToken = env.MCP_AUTH_TOKEN?.trim();
const oauthIssuer = env.MCP_OAUTH_ISSUER?.trim();
const oauthAudience = env.MCP_OAUTH_AUDIENCE?.trim();
const publicUrl = env.MCP_PUBLIC_URL?.trim();
const oauthJwksUri = env.MCP_OAUTH_JWKS_URI?.trim();
const oauthRequiredScopes = env.MCP_OAUTH_REQUIRED_SCOPES?.trim();
const oauthAllowedSubjects = env.MCP_OAUTH_ALLOWED_SUBJECTS?.trim();
const hasOAuthIntent = Boolean(
oauthIssuer || oauthAudience || publicUrl || oauthJwksUri || oauthRequiredScopes || oauthAllowedSubjects,
);
if (authToken && hasOAuthIntent) {
throw new Error("Configure either MCP_AUTH_TOKEN or MCP_OAUTH_ISSUER, not both.");
}
if (hasOAuthIntent) {
if (!oauthIssuer || !oauthAudience || !publicUrl || !oauthJwksUri || !oauthAllowedSubjects) {
throw new Error(
"MCP_OAUTH_ISSUER, MCP_OAUTH_AUDIENCE, MCP_OAUTH_JWKS_URI, MCP_PUBLIC_URL, and " +
"MCP_OAUTH_ALLOWED_SUBJECTS are all required for OAuth.",
);
}
const issuer = parseSecureUrl(oauthIssuer, "MCP_OAUTH_ISSUER", env.NODE_ENV);
const audience = parseSecureUrl(oauthAudience, "MCP_OAUTH_AUDIENCE", env.NODE_ENV);
const canonicalPublicUrl = parseSecureUrl(publicUrl, "MCP_PUBLIC_URL", env.NODE_ENV);
const jwksUri = parseSecureUrl(oauthJwksUri, "MCP_OAUTH_JWKS_URI", env.NODE_ENV);
if (issuer.search) {
throw new Error("MCP_OAUTH_ISSUER must not contain a query.");
}
if (canonicalPublicUrl.pathname !== "/" || canonicalPublicUrl.search || canonicalPublicUrl.hash) {
throw new Error("MCP_PUBLIC_URL must be an origin without a path, query, or fragment.");
}
if (audience.href !== `${canonicalPublicUrl.origin}/mcp`) {
throw new Error("MCP_OAUTH_AUDIENCE must exactly equal MCP_PUBLIC_URL plus /mcp.");
}
const requiredScopes = (oauthRequiredScopes ?? "premiere:mcp")
.split(/[ ,]+/)
.map((scope) => scope.trim())
.filter(Boolean);
if (requiredScopes.length === 0 || requiredScopes.some((scope) => !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))) {
throw new Error("MCP_OAUTH_REQUIRED_SCOPES must contain one or more valid OAuth scope values.");
}
const allowedSubjects = oauthAllowedSubjects
.split(",")
.map((subject) => subject.trim())
.filter(Boolean);
if (
allowedSubjects.length === 0 ||
allowedSubjects.some((subject) => subject.length > 255 || /[\u0000-\u001F\u007F]/.test(subject))
) {
throw new Error("MCP_OAUTH_ALLOWED_SUBJECTS must contain valid comma-separated token subjects.");
}
return {
mode: "oauth",
oauth: {
issuer: issuer.pathname === "/" && !issuer.search ? issuer.origin : issuer.href,
audience: audience.href,
publicUrl: canonicalPublicUrl.origin,
jwksUri: jwksUri.href,
requiredScopes: [...new Set(requiredScopes)],
allowedSubjects: [...new Set(allowedSubjects)],
},
allowUnauthenticated: false,
};
}
if (authToken) return { mode: "shared-token", authToken, allowUnauthenticated: false };
if (env.ALLOW_UNAUTHENTICATED === "1" && env.NODE_ENV !== "production") {
return { mode: "unauthenticated", allowUnauthenticated: true };
}
throw new Error(
"MCP_AUTH_TOKEN is required for the HTTP transport. " +
"ALLOW_UNAUTHENTICATED=1 is permitted only outside NODE_ENV=production.",
);
}
function parseSecureUrl(raw: string, name: string, nodeEnv: string | undefined): URL {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`${name} must be an absolute URL.`);
}
const localDevelopment = nodeEnv !== "production" &&
parsed.protocol === "http:" &&
(parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]");
if (parsed.protocol !== "https:" && !localDevelopment) {
throw new Error(`${name} must use HTTPS (HTTP is allowed only for loopback development).`);
}
if (parsed.username || parsed.password || parsed.hash) {
throw new Error(`${name} must not contain credentials or a fragment.`);
}
return parsed;
}
export function getRequestPathname(rawUrl: string | undefined): string | undefined {
if (!rawUrl) return undefined;
try {
return new URL(rawUrl, "http://localhost").pathname;
} catch {
return undefined;
}
}
export function isSupportedMcpMethod(method: string | undefined): boolean {
return MCP_HTTP_METHODS.some((allowed) => allowed === method);
}
export function requestContentLength(req: Pick<http.IncomingMessage, "headers">): number | undefined {
const header = req.headers["content-length"];
const value = Array.isArray(header) ? header[0] : header;
if (value === undefined) return undefined;
if (!/^\d+$/.test(value)) return Number.NaN;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : Number.NaN;
}
export function exceedsRequestBodyLimit(
req: Pick<http.IncomingMessage, "headers">,
maxRequestBytes: number,
): boolean {
const contentLength = requestContentLength(req);
return contentLength !== undefined && (!Number.isFinite(contentLength) || contentLength > maxRequestBytes);
}
export class RequestBodyTooLargeError extends Error {
constructor() {
super("Request body too large");
this.name = "RequestBodyTooLargeError";
}
}
/**
* Reads an MCP request body with a hard byte cap before it reaches the transport.
* This avoids attaching a second live data listener beside the transport, which
* can otherwise race and consume a fast chunked body before the transport does.
*/
export function readBoundedRequestBody(req: http.IncomingMessage, maxRequestBytes: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let receivedBytes = 0;
const cleanup = () => {
req.off("data", onData);
req.off("end", onEnd);
req.off("error", onError);
req.off("aborted", onAborted);
};
const onData = (chunk: unknown) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
receivedBytes += buffer.length;
if (receivedBytes <= maxRequestBytes) {
chunks.push(buffer);
return;
}
cleanup();
// Drain rather than destroy so the caller can reliably send its 413.
req.resume();
reject(new RequestBodyTooLargeError());
};
const onEnd = () => {
cleanup();
resolve(Buffer.concat(chunks));
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onAborted = () => {
cleanup();
reject(new Error("Request aborted"));
};
req.on("data", onData);
req.once("end", onEnd);
req.once("error", onError);
req.once("aborted", onAborted);
});
}
export function isAuthorizedBearer(req: Pick<http.IncomingMessage, "headers">, authToken: string | undefined): boolean {
if (!authToken) return true;
const header = req.headers.authorization;
const value = Array.isArray(header) ? header[0] : header ?? "";
if (!value.startsWith("Bearer ")) return false;
const provided = Buffer.from(value.slice(7));
const expected = Buffer.from(authToken);
if (provided.length !== expected.length) return false;
return timingSafeBufferEqual(provided, expected);
}
function timingSafeBufferEqual(left: Buffer, right: Buffer): boolean {
return timingSafeEqual(left, right);
}
function hashedIdentity(value: string): string {
// A process-local keyed digest prevents network addresses from being
// recovered through an offline dictionary attack if a bucket key
// is ever observed. The key and derived identities are never persisted.
return createHmac("sha256", RATE_LIMIT_IDENTITY_KEY).update(value).digest("hex").slice(0, 32);
}
/**
* The edge is authoritative by default. Honor X-Forwarded-For only after an
* operator explicitly declares the proxy trusted; otherwise it is attacker
* input and must not be used as a rate-limit identity.
*/
export function rateLimitIdentity(
req: Pick<http.IncomingMessage, "headers" | "socket">,
trustProxy: boolean,
): string {
const forwarded = req.headers["x-forwarded-for"];
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const remoteAddress = trustProxy && forwardedValue
? forwardedValue.split(",")[0].trim()
: req.socket?.remoteAddress ?? "unknown";
return `ip:${hashedIdentity(remoteAddress || "unknown")}`;
}
interface TokenBucket {
tokens: number;
updatedAt: number;
}
/**
* Bounded, process-local protection for a single machine. It deliberately does
* not log or export identities. An edge/WAF remains necessary for fleet-wide
* protection across restarts and multiple instances.
*/
export class HttpAdmissionController {
private readonly buckets = new Map<string, TokenBucket>();
private activeOperationRequests = 0;
private activeStreamRequests = 0;
constructor(
private readonly settings: Pick<HttpAdmissionSettings, "maxConcurrentRequests" | "maxConcurrentStreams" | "rateLimitPerMinute" | "rateLimitBurst" | "maxRateLimitKeys">,
private readonly clock: () => number = Date.now,
) {}
acquire(identity: string, lane: AdmissionLane = "operation"): AdmissionDecision {
const now = this.clock();
this.pruneIdleBuckets(now);
const bucket = this.getOrCreateBucket(identity, now);
if (!bucket) {
return { accepted: false, reason: "rate_limited", statusCode: 429, retryAfterSeconds: 60 };
}
const elapsed = Math.max(0, now - bucket.updatedAt);
const refill = elapsed * (this.settings.rateLimitPerMinute / ONE_MINUTE_MS);
bucket.tokens = Math.min(this.settings.rateLimitBurst, bucket.tokens + refill);
bucket.updatedAt = now;
if (bucket.tokens < 1) {
const missing = 1 - bucket.tokens;
const retryAfterSeconds = Math.max(1, Math.ceil((missing / this.settings.rateLimitPerMinute) * 60));
return { accepted: false, reason: "rate_limited", statusCode: 429, retryAfterSeconds };
}
const activeRequests = lane === "stream" ? this.activeStreamRequests : this.activeOperationRequests;
const maximumRequests = lane === "stream" ? this.settings.maxConcurrentStreams : this.settings.maxConcurrentRequests;
if (activeRequests >= maximumRequests) {
return { accepted: false, reason: "at_capacity", statusCode: 503, retryAfterSeconds: 1 };
}
bucket.tokens -= 1;
if (lane === "stream") this.activeStreamRequests += 1;
else this.activeOperationRequests += 1;
let released = false;
return {
accepted: true,
release: () => {
if (released) return;
released = true;
if (lane === "stream") this.activeStreamRequests = Math.max(0, this.activeStreamRequests - 1);
else this.activeOperationRequests = Math.max(0, this.activeOperationRequests - 1);
},
};
}
metrics(): AdmissionMetrics {
return {
activeRequests: this.activeOperationRequests + this.activeStreamRequests,
activeOperationRequests: this.activeOperationRequests,
activeStreamRequests: this.activeStreamRequests,
trackedRateLimitKeys: this.buckets.size,
};
}
private getOrCreateBucket(identity: string, now: number): TokenBucket | undefined {
const existing = this.buckets.get(identity);
if (existing) return existing;
if (this.buckets.size >= this.settings.maxRateLimitKeys) return undefined;
const bucket = { tokens: this.settings.rateLimitBurst, updatedAt: now };
this.buckets.set(identity, bucket);
return bucket;
}
private pruneIdleBuckets(now: number): void {
const maxIdleMs = Math.max(ONE_MINUTE_MS, Math.ceil((this.settings.rateLimitBurst / this.settings.rateLimitPerMinute) * ONE_MINUTE_MS) * 2);
for (const [identity, bucket] of this.buckets) {
if (now - bucket.updatedAt > maxIdleMs) this.buckets.delete(identity);
}
}
}