diff --git a/CHANGELOG.md b/CHANGELOG.md index efff5dc..6045852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.0.6 - 2026-04-04 + +### Added + +- `@4nk/smart-ide-http-utils`: shared HTTP proxy helpers (Bearer/body limit/hop-by-hop headers/safe proxy paths). + +### Changed + +- `smart-ide-sso-gateway` and `smart-ide-global-api`: reuse shared HTTP helpers (reduces duplication). +- `IA_DEV_ROOT` resolution: prefer `./services/ia_dev` then `./ia_dev` (code + docs); fail fast if missing in `ia-dev-gateway`. +- `scripts/ensure-ia-dev-project-link.sh`: prefer `services/ia_dev` when both layouts exist. + ## 0.0.5 - 2026-04-04 ### Added diff --git a/VERSION b/VERSION index bbdeab6..1750564 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.5 +0.0.6 diff --git a/docs/ia_dev-module.md b/docs/ia_dev-module.md index 2ad52ff..005d7c2 100644 --- a/docs/ia_dev-module.md +++ b/docs/ia_dev-module.md @@ -30,7 +30,7 @@ Le périmètre « service » côté monorepo est documenté sous [repo/ia-de | **smart_ide** | Cible UX IDE, scripts socle, systemd, doc de déploiement, **`logs/`** | | **`services/ia_dev/`** (module dans ce dépôt) | Agents, déploiements, ticketing ; confs projet dans `./projects/` + liens sous `services/ia_dev/projects/` | -Le futur **agent gateway** doit traiter `IA_DEV_ROOT` comme chemin canonique sur le serveur (résolution par défaut : `./ia_dev` puis `./services/ia_dev`). Voir [system-architecture.md](./system-architecture.md). +Le futur **agent gateway** doit traiter `IA_DEV_ROOT` comme chemin canonique sur le serveur (résolution par défaut : `./services/ia_dev` puis `./ia_dev`). Voir [system-architecture.md](./system-architecture.md). ## Trajectoire : service `ia-dev-gateway` diff --git a/docs/repo/README.md b/docs/repo/README.md index a28ce55..33180ba 100644 --- a/docs/repo/README.md +++ b/docs/repo/README.md @@ -45,6 +45,7 @@ Toute la documentation **opérationnelle** qui vivait auparavant sous des `READM | [service-smart-ide-global-api.md](./service-smart-ide-global-api.md) | API HTTP interne : proxy vers micro-services (Bearer partagé avec SSO) | | [service-smart-ide-sso-gateway.md](./service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → API globale → micro-services | | [../packages/smart-ide-upstreams/README.md](../packages/smart-ide-upstreams/README.md) | Paquet `@4nk/smart-ide-upstreams` : liste des clés et résolution des URL / jetons | +| [../packages/smart-ide-http-utils/README.md](../packages/smart-ide-http-utils/README.md) | Paquet `@4nk/smart-ide-http-utils` : helpers HTTP partagés (proxy, headers, safe path) | | [extension-anythingllm-workspaces.md](./extension-anythingllm-workspaces.md) | Extension AnythingLLM IDE (supprimée ; anythingllm-devtools) | Les **spécifications** détaillées (contrats HTTP, sécurité, orchestration) restent dans [../API/README.md](../API/README.md) et [../features/](../features/). diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 2524ff2..cedd47d 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -56,7 +56,7 @@ Chaque environnement possède ses **URLs**, **secrets** et **politiques** (Anyth ## Module `ia_dev` dans ce dépôt -Le répertoire **`./services/ia_dev`** fait partie du dépôt **smart_ide** (référence historique : [4nk/ia_dev](https://git.4nkweb.com/4nk/ia_dev.git) sur la forge). Sur le serveur SSH, l’**agent gateway** et les outils peuvent pointer vers `IA_DEV_ROOT` (résolution par défaut : `./ia_dev` puis `./services/ia_dev`) comme racine d’exécution des agents (scripts invoqués depuis la racine `ia_dev`). Voir [ia_dev-module.md](./ia_dev-module.md) et [repo/ia-dev-smart-ide-integration.md](./repo/ia-dev-smart-ide-integration.md). +Le répertoire **`./services/ia_dev`** fait partie du dépôt **smart_ide** (référence historique : [4nk/ia_dev](https://git.4nkweb.com/4nk/ia_dev.git) sur la forge). Sur le serveur SSH, l’**agent gateway** et les outils peuvent pointer vers `IA_DEV_ROOT` (résolution par défaut : `./services/ia_dev` puis `./ia_dev`) comme racine d’exécution des agents (scripts invoqués depuis la racine `ia_dev`). Voir [ia_dev-module.md](./ia_dev-module.md) et [repo/ia-dev-smart-ide-integration.md](./repo/ia-dev-smart-ide-integration.md). ## Répartition physique (première cible) diff --git a/packages/smart-ide-http-utils/.gitignore b/packages/smart-ide-http-utils/.gitignore new file mode 100644 index 0000000..d570088 --- /dev/null +++ b/packages/smart-ide-http-utils/.gitignore @@ -0,0 +1,2 @@ +node_modules/ + diff --git a/packages/smart-ide-http-utils/README.md b/packages/smart-ide-http-utils/README.md new file mode 100644 index 0000000..4149d4d --- /dev/null +++ b/packages/smart-ide-http-utils/README.md @@ -0,0 +1,28 @@ +# @4nk/smart-ide-http-utils + +Utilitaires HTTP partagés pour les services Node/TypeScript du monorepo **smart_ide**. + +Objectifs : + +- Réduire la duplication (Bearer, limites de body, headers hop-by-hop, etc.). +- Centraliser les garde-fous de proxy (ex. rejet des segments `..` dans les chemins relayés). + +Fonctions exposées : + +- `readBearer(req)` : lit `Authorization: Bearer …`. +- `readBodyBuffer(req, maxBytes)` : lit un corps en mémoire avec plafond explicite. +- `copyHeadersForProxy(req)` : copie les headers d’entrée en excluant hop-by-hop + `Authorization`. +- `isSafeProxyPath(path)` : valide un chemin relayé (refuse `.` / `..` même encodés). +- `REQUEST_HOP_BY_HOP_HEADERS`, `RESPONSE_HOP_BY_HOP_HEADERS`. + +## Build + +Le répertoire **`dist/`** est versionné pour que les services puissent installer ce paquet via `file:` sans étape de build préalable. + +Après modification de `src/` : + +```bash +cd packages/smart-ide-http-utils +npm ci && npm run build +``` + diff --git a/packages/smart-ide-http-utils/dist/index.d.ts b/packages/smart-ide-http-utils/dist/index.d.ts new file mode 100644 index 0000000..6ad3997 --- /dev/null +++ b/packages/smart-ide-http-utils/dist/index.d.ts @@ -0,0 +1,2 @@ +export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, isSafeProxyPath, } from "./proxy.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/smart-ide-http-utils/dist/index.d.ts.map b/packages/smart-ide-http-utils/dist/index.d.ts.map new file mode 100644 index 0000000..5f44206 --- /dev/null +++ b/packages/smart-ide-http-utils/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,2BAA2B,EAC3B,UAAU,EACV,cAAc,EACd,mBAAmB,EACnB,eAAe,GAChB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/smart-ide-http-utils/dist/index.js b/packages/smart-ide-http-utils/dist/index.js new file mode 100644 index 0000000..23ede23 --- /dev/null +++ b/packages/smart-ide-http-utils/dist/index.js @@ -0,0 +1 @@ +export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, isSafeProxyPath, } from "./proxy.js"; diff --git a/packages/smart-ide-http-utils/dist/proxy.d.ts b/packages/smart-ide-http-utils/dist/proxy.d.ts new file mode 100644 index 0000000..1eb77df --- /dev/null +++ b/packages/smart-ide-http-utils/dist/proxy.d.ts @@ -0,0 +1,10 @@ +import type * as http from "node:http"; +export declare const REQUEST_HOP_BY_HOP_HEADERS: Set; +export declare const RESPONSE_HOP_BY_HOP_HEADERS: Set; +export declare const readBearer: (req: http.IncomingMessage) => string | null; +export declare const readBodyBuffer: (req: http.IncomingMessage, maxBytes: number) => Promise; +export declare const copyHeadersForProxy: (req: http.IncomingMessage, opts?: { + skipLowercase?: ReadonlySet; +}) => Headers; +export declare const isSafeProxyPath: (p: string) => boolean; +//# sourceMappingURL=proxy.d.ts.map \ No newline at end of file diff --git a/packages/smart-ide-http-utils/dist/proxy.d.ts.map b/packages/smart-ide-http-utils/dist/proxy.d.ts.map new file mode 100644 index 0000000..7fc84e6 --- /dev/null +++ b/packages/smart-ide-http-utils/dist/proxy.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AAEvC,eAAO,MAAM,0BAA0B,aAUrC,CAAC;AAEH,eAAO,MAAM,2BAA2B,aAKtC,CAAC;AAEH,eAAO,MAAM,UAAU,GAAI,KAAK,IAAI,CAAC,eAAe,KAAG,MAAM,GAAG,IAI/D,CAAC;AAEF,eAAO,MAAM,cAAc,GACzB,KAAK,IAAI,CAAC,eAAe,EACzB,UAAU,MAAM,KACf,OAAO,CAAC,MAAM,CAYhB,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,KAAK,IAAI,CAAC,eAAe,EACzB,OAAO;IAAE,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CAAE,KAC7C,OAmBF,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,GAAG,MAAM,KAAG,OAyB3C,CAAC"} \ No newline at end of file diff --git a/packages/smart-ide-http-utils/dist/proxy.js b/packages/smart-ide-http-utils/dist/proxy.js new file mode 100644 index 0000000..dd78957 --- /dev/null +++ b/packages/smart-ide-http-utils/dist/proxy.js @@ -0,0 +1,82 @@ +export const REQUEST_HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", +]); +export const RESPONSE_HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "content-encoding", +]); +export const readBearer = (req) => { + const raw = req.headers.authorization ?? ""; + const m = /^Bearer\s+(.+)$/i.exec(raw); + return m?.[1]?.trim() ?? null; +}; +export const readBodyBuffer = async (req, maxBytes) => { + const chunks = []; + let total = 0; + for await (const chunk of req) { + const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + total += b.length; + if (total > maxBytes) { + throw new Error(`Request body exceeds ${maxBytes} bytes`); + } + chunks.push(b); + } + return Buffer.concat(chunks); +}; +export const copyHeadersForProxy = (req, opts) => { + const out = new Headers(); + for (const [k, v] of Object.entries(req.headers)) { + if (!v) { + continue; + } + const lk = k.toLowerCase(); + if (REQUEST_HOP_BY_HOP_HEADERS.has(lk)) { + continue; + } + if (lk === "authorization") { + continue; + } + if (opts?.skipLowercase?.has(lk)) { + continue; + } + out.set(k, Array.isArray(v) ? v.join(", ") : v); + } + return out; +}; +export const isSafeProxyPath = (p) => { + if (!p.startsWith("/")) { + return false; + } + for (const rawSeg of p.split("/")) { + if (rawSeg.length === 0) { + continue; + } + if (rawSeg === "." || rawSeg === "..") { + return false; + } + let seg; + try { + seg = decodeURIComponent(rawSeg); + } + catch { + return false; + } + if (seg === "." || seg === "..") { + return false; + } + if (seg.includes("/") || seg.includes("\\")) { + return false; + } + } + return true; +}; diff --git a/packages/smart-ide-http-utils/package-lock.json b/packages/smart-ide-http-utils/package-lock.json new file mode 100644 index 0000000..dd5edc4 --- /dev/null +++ b/packages/smart-ide-http-utils/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@4nk/smart-ide-http-utils", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@4nk/smart-ide-http-utils", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/smart-ide-http-utils/package.json b/packages/smart-ide-http-utils/package.json new file mode 100644 index 0000000..e15e36a --- /dev/null +++ b/packages/smart-ide-http-utils/package.json @@ -0,0 +1,27 @@ +{ + "name": "@4nk/smart-ide-http-utils", + "version": "0.1.0", + "private": true, + "description": "Shared HTTP helpers for smart_ide Node services (proxy headers, body limits, safe paths).", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p ." + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + } +} + diff --git a/packages/smart-ide-http-utils/src/index.ts b/packages/smart-ide-http-utils/src/index.ts new file mode 100644 index 0000000..d2b54c4 --- /dev/null +++ b/packages/smart-ide-http-utils/src/index.ts @@ -0,0 +1,9 @@ +export { + REQUEST_HOP_BY_HOP_HEADERS, + RESPONSE_HOP_BY_HOP_HEADERS, + readBearer, + readBodyBuffer, + copyHeadersForProxy, + isSafeProxyPath, +} from "./proxy.js"; + diff --git a/packages/smart-ide-http-utils/src/proxy.ts b/packages/smart-ide-http-utils/src/proxy.ts new file mode 100644 index 0000000..ca8ac7d --- /dev/null +++ b/packages/smart-ide-http-utils/src/proxy.ts @@ -0,0 +1,95 @@ +import type * as http from "node:http"; + +export const REQUEST_HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", +]); + +export const RESPONSE_HOP_BY_HOP_HEADERS = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "content-encoding", +]); + +export const readBearer = (req: http.IncomingMessage): string | null => { + const raw = req.headers.authorization ?? ""; + const m = /^Bearer\s+(.+)$/i.exec(raw); + return m?.[1]?.trim() ?? null; +}; + +export const readBodyBuffer = async ( + req: http.IncomingMessage, + maxBytes: number, +): Promise => { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + total += b.length; + if (total > maxBytes) { + throw new Error(`Request body exceeds ${maxBytes} bytes`); + } + chunks.push(b); + } + return Buffer.concat(chunks); +}; + +export const copyHeadersForProxy = ( + req: http.IncomingMessage, + opts?: { skipLowercase?: ReadonlySet }, +): Headers => { + const out = new Headers(); + for (const [k, v] of Object.entries(req.headers)) { + if (!v) { + continue; + } + const lk = k.toLowerCase(); + if (REQUEST_HOP_BY_HOP_HEADERS.has(lk)) { + continue; + } + if (lk === "authorization") { + continue; + } + if (opts?.skipLowercase?.has(lk)) { + continue; + } + out.set(k, Array.isArray(v) ? v.join(", ") : v); + } + return out; +}; + +export const isSafeProxyPath = (p: string): boolean => { + if (!p.startsWith("/")) { + return false; + } + for (const rawSeg of p.split("/")) { + if (rawSeg.length === 0) { + continue; + } + if (rawSeg === "." || rawSeg === "..") { + return false; + } + let seg: string; + try { + seg = decodeURIComponent(rawSeg); + } catch { + return false; + } + if (seg === "." || seg === "..") { + return false; + } + if (seg.includes("/") || seg.includes("\\")) { + return false; + } + } + return true; +}; + diff --git a/packages/smart-ide-http-utils/tsconfig.json b/packages/smart-ide-http-utils/tsconfig.json new file mode 100644 index 0000000..d98657d --- /dev/null +++ b/packages/smart-ide-http-utils/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"] +} + diff --git a/scripts/ensure-ia-dev-project-link.sh b/scripts/ensure-ia-dev-project-link.sh index 6a6b3a5..5a4467f 100755 --- a/scripts/ensure-ia-dev-project-link.sh +++ b/scripts/ensure-ia-dev-project-link.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Create ia_dev/projects/ -> ../../projects/ so ia_dev scripts resolve conf.json +# Create IA_DEV_ROOT/projects/ -> ../../projects/ so ia_dev scripts resolve conf.json # from the monorepo versioned projects//. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -12,12 +12,12 @@ if [[ ! -f "${CONF}" ]]; then fi IA_DEV_DIR="" -if [[ -d "${ROOT}/ia_dev" ]]; then - IA_DEV_DIR="${ROOT}/ia_dev" -elif [[ -d "${ROOT}/services/ia_dev" ]]; then +if [[ -d "${ROOT}/services/ia_dev" ]]; then IA_DEV_DIR="${ROOT}/services/ia_dev" +elif [[ -d "${ROOT}/ia_dev" ]]; then + IA_DEV_DIR="${ROOT}/ia_dev" else - echo "Missing ia_dev directory: expected '${ROOT}/ia_dev' or '${ROOT}/services/ia_dev'" >&2 + echo "Missing ia_dev directory: expected '${ROOT}/services/ia_dev' or '${ROOT}/ia_dev'" >&2 exit 1 fi diff --git a/services/ia-dev-gateway/src/paths.ts b/services/ia-dev-gateway/src/paths.ts index c93c84f..b52d864 100644 --- a/services/ia-dev-gateway/src/paths.ts +++ b/services/ia-dev-gateway/src/paths.ts @@ -12,24 +12,32 @@ export const dirExists = (p: string): boolean => { } }; -/** Path to ia_dev checkout: IA_DEV_ROOT env or monorepo ./ia_dev or ./services/ia_dev */ +/** Path to ia_dev checkout: IA_DEV_ROOT env or monorepo ./services/ia_dev or ./ia_dev */ export const getIaDevRoot = (): string => { const fromEnv = process.env.IA_DEV_ROOT?.trim(); if (fromEnv && fromEnv.length > 0) { - return path.resolve(fromEnv); + const resolved = path.resolve(fromEnv); + if (!dirExists(resolved)) { + throw new Error(`IA_DEV_ROOT is set but is not a directory: ${resolved}`); + } + return resolved; } const monorepoRoot = path.resolve(__dirname, "..", "..", ".."); const candidates = [ - path.join(monorepoRoot, "ia_dev"), path.join(monorepoRoot, "services", "ia_dev"), + path.join(monorepoRoot, "ia_dev"), ]; for (const c of candidates) { if (dirExists(c)) { return c; } } - return candidates[0]; + throw new Error( + `Missing ia_dev directory. Set IA_DEV_ROOT or create one of: ${candidates + .map((c) => JSON.stringify(c)) + .join(", ")}`, + ); }; export const agentsDir = (iaDevRoot: string): string => diff --git a/services/smart-ide-global-api/package-lock.json b/services/smart-ide-global-api/package-lock.json index 838e63a..5dd3947 100644 --- a/services/smart-ide-global-api/package-lock.json +++ b/services/smart-ide-global-api/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils", "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams" }, "devDependencies": { @@ -19,6 +20,18 @@ "node": ">=20" } }, + "../../packages/smart-ide-http-utils": { + "name": "@4nk/smart-ide-http-utils", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, "../../packages/smart-ide-upstreams": { "name": "@4nk/smart-ide-upstreams", "version": "0.1.0", @@ -31,6 +44,10 @@ "node": ">=20" } }, + "node_modules/@4nk/smart-ide-http-utils": { + "resolved": "../../packages/smart-ide-http-utils", + "link": true + }, "node_modules/@4nk/smart-ide-upstreams": { "resolved": "../../packages/smart-ide-upstreams", "link": true diff --git a/services/smart-ide-global-api/package.json b/services/smart-ide-global-api/package.json index 562b58d..ecf29bc 100644 --- a/services/smart-ide-global-api/package.json +++ b/services/smart-ide-global-api/package.json @@ -14,6 +14,7 @@ "node": ">=20" }, "dependencies": { + "@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils", "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams" }, "devDependencies": { diff --git a/services/smart-ide-global-api/src/server.ts b/services/smart-ide-global-api/src/server.ts index a221f63..94e60bb 100644 --- a/services/smart-ide-global-api/src/server.ts +++ b/services/smart-ide-global-api/src/server.ts @@ -4,6 +4,13 @@ import { resolveUpstream, type UpstreamAuth, } from "@4nk/smart-ide-upstreams"; +import { + RESPONSE_HOP_BY_HOP_HEADERS, + copyHeadersForProxy, + isSafeProxyPath, + readBearer, + readBodyBuffer, +} from "@4nk/smart-ide-http-utils"; import { appendGlobalApiAccessLog } from "./accessLog.js"; const HOST = process.env.GLOBAL_API_HOST ?? "127.0.0.1"; @@ -17,80 +24,8 @@ const json = (res: http.ServerResponse, status: number, body: unknown): void => res.end(JSON.stringify(body)); }; -const readBearer = (req: http.IncomingMessage): string | null => { - const raw = req.headers.authorization ?? ""; - const m = /^Bearer\s+(.+)$/i.exec(raw); - return m?.[1]?.trim() ?? null; -}; - -const isSafeProxyPath = (p: string): boolean => { - if (!p.startsWith("/")) { - return false; - } - for (const rawSeg of p.split("/")) { - if (rawSeg.length === 0) { - continue; - } - if (rawSeg === "." || rawSeg === "..") { - return false; - } - let seg: string; - try { - seg = decodeURIComponent(rawSeg); - } catch { - return false; - } - if (seg === "." || seg === "..") { - return false; - } - if (seg.includes("/") || seg.includes("\\")) { - return false; - } - } - return true; -}; - -const readBodyBuffer = async (req: http.IncomingMessage): Promise => { - const chunks: Buffer[] = []; - let total = 0; - for await (const chunk of req) { - const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk; - total += b.length; - if (total > MAX_BODY_BYTES) { - throw new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`); - } - chunks.push(b); - } - return Buffer.concat(chunks); -}; - -const hopByHop = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "host", -]); - const buildForwardHeaders = (req: http.IncomingMessage, serviceAuth: UpstreamAuth): Headers => { - const out = new Headers(); - for (const [k, v] of Object.entries(req.headers)) { - if (!v) { - continue; - } - const lk = k.toLowerCase(); - if (hopByHop.has(lk)) { - continue; - } - if (lk === "authorization") { - continue; - } - out.set(k, Array.isArray(v) ? v.join(", ") : v); - } + const out = copyHeadersForProxy(req); if (serviceAuth.kind === "bearer") { if (serviceAuth.token) { out.set("Authorization", `Bearer ${serviceAuth.token}`); @@ -101,13 +36,6 @@ const buildForwardHeaders = (req: http.IncomingMessage, serviceAuth: UpstreamAut return out; }; -const responseHopByHop = new Set([ - "connection", - "keep-alive", - "transfer-encoding", - "content-encoding", -]); - const proxyToUpstream = async ( res: http.ServerResponse, targetUrl: string, @@ -126,7 +54,7 @@ const proxyToUpstream = async ( const out = await fetch(targetUrl, init); res.statusCode = out.status; for (const [k, v] of out.headers) { - if (responseHopByHop.has(k.toLowerCase())) { + if (RESPONSE_HOP_BY_HOP_HEADERS.has(k.toLowerCase())) { continue; } res.setHeader(k, v); @@ -208,7 +136,7 @@ const main = (): void => { return; } - const body = await readBodyBuffer(req); + const body = await readBodyBuffer(req, MAX_BODY_BYTES); const targetUrl = `${upstream.baseUrl}${rest}${url.search}`; const headers = buildForwardHeaders(req, upstream.auth); status = await proxyToUpstream(res, targetUrl, headers, body, method); diff --git a/services/smart-ide-sso-gateway/package-lock.json b/services/smart-ide-sso-gateway/package-lock.json index 9576759..212de94 100644 --- a/services/smart-ide-sso-gateway/package-lock.json +++ b/services/smart-ide-sso-gateway/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils", "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams", "jose": "^5.9.6" }, @@ -20,6 +21,18 @@ "node": ">=20" } }, + "../../packages/smart-ide-http-utils": { + "name": "@4nk/smart-ide-http-utils", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, "../../packages/smart-ide-upstreams": { "name": "@4nk/smart-ide-upstreams", "version": "0.1.0", @@ -32,6 +45,10 @@ "node": ">=20" } }, + "node_modules/@4nk/smart-ide-http-utils": { + "resolved": "../../packages/smart-ide-http-utils", + "link": true + }, "node_modules/@4nk/smart-ide-upstreams": { "resolved": "../../packages/smart-ide-upstreams", "link": true diff --git a/services/smart-ide-sso-gateway/package.json b/services/smart-ide-sso-gateway/package.json index 25a36a1..3e43669 100644 --- a/services/smart-ide-sso-gateway/package.json +++ b/services/smart-ide-sso-gateway/package.json @@ -14,6 +14,7 @@ "node": ">=20" }, "dependencies": { + "@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils", "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams", "jose": "^5.9.6" }, diff --git a/services/smart-ide-sso-gateway/src/server.ts b/services/smart-ide-sso-gateway/src/server.ts index 1e370c2..a175e84 100644 --- a/services/smart-ide-sso-gateway/src/server.ts +++ b/services/smart-ide-sso-gateway/src/server.ts @@ -3,6 +3,13 @@ import type { JWTPayload } from "jose"; import { appendSsoAccessLog } from "./accessLog.js"; import { discoverJwksUri, createVerify, type VerifyFn } from "./oidc.js"; import { listUpstreamKeys } from "@4nk/smart-ide-upstreams"; +import { + RESPONSE_HOP_BY_HOP_HEADERS, + copyHeadersForProxy, + isSafeProxyPath, + readBearer, + readBodyBuffer, +} from "@4nk/smart-ide-http-utils"; const HOST = process.env.SSO_GATEWAY_HOST ?? "127.0.0.1"; const PORT = Number(process.env.SSO_GATEWAY_PORT ?? "37148"); @@ -71,83 +78,11 @@ const json = (res: http.ServerResponse, status: number, body: unknown): void => res.end(JSON.stringify(body)); }; -const readBearer = (req: http.IncomingMessage): string | null => { - const raw = req.headers.authorization ?? ""; - const m = /^Bearer\s+(.+)$/i.exec(raw); - return m?.[1]?.trim() ?? null; -}; - -const isSafeProxyPath = (p: string): boolean => { - if (!p.startsWith("/")) { - return false; - } - for (const rawSeg of p.split("/")) { - if (rawSeg.length === 0) { - continue; - } - if (rawSeg === "." || rawSeg === "..") { - return false; - } - let seg: string; - try { - seg = decodeURIComponent(rawSeg); - } catch { - return false; - } - if (seg === "." || seg === "..") { - return false; - } - if (seg.includes("/") || seg.includes("\\")) { - return false; - } - } - return true; -}; - -const readBodyBuffer = async (req: http.IncomingMessage): Promise => { - const chunks: Buffer[] = []; - let total = 0; - for await (const chunk of req) { - const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk; - total += b.length; - if (total > MAX_BODY_BYTES) { - throw new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`); - } - chunks.push(b); - } - return Buffer.concat(chunks); -}; - -const hopByHop = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "host", -]); - const buildForwardHeadersToGlobalApi = ( req: http.IncomingMessage, payload: JWTPayload, ): Headers => { - const out = new Headers(); - for (const [k, v] of Object.entries(req.headers)) { - if (!v) { - continue; - } - const lk = k.toLowerCase(); - if (hopByHop.has(lk)) { - continue; - } - if (lk === "authorization") { - continue; - } - out.set(k, Array.isArray(v) ? v.join(", ") : v); - } + const out = copyHeadersForProxy(req); const gToken = globalApiToken(); if (gToken) { out.set("Authorization", `Bearer ${gToken}`); @@ -163,13 +98,6 @@ const buildForwardHeadersToGlobalApi = ( return out; }; -const responseHopByHop = new Set([ - "connection", - "keep-alive", - "transfer-encoding", - "content-encoding", -]); - const proxyToGlobalApi = async ( req: http.IncomingMessage, res: http.ServerResponse, @@ -190,7 +118,7 @@ const proxyToGlobalApi = async ( applyCors(res); res.statusCode = out.status; for (const [k, v] of out.headers) { - if (responseHopByHop.has(k.toLowerCase())) { + if (RESPONSE_HOP_BY_HOP_HEADERS.has(k.toLowerCase())) { continue; } res.setHeader(k, v); @@ -307,7 +235,7 @@ const main = async (): Promise => { } logPath = pathname; const targetUrl = `${globalApiBase()}/v1/upstream/${upstreamKey}${rest}${url.search}`; - const body = await readBodyBuffer(req); + const body = await readBodyBuffer(req, MAX_BODY_BYTES); const headers = buildForwardHeadersToGlobalApi(req, payload); status = await proxyToGlobalApi(req, res, targetUrl, headers, body); } catch (e) {