refactor: centralize HTTP proxy helpers and align IA_DEV_ROOT resolution (0.0.6)

Initial state:
- HTTP proxy utilities (Bearer parsing, hop-by-hop headers, body limits, safe path validation) were duplicated between smart-ide-sso-gateway and smart-ide-global-api.
- IA_DEV_ROOT auto-resolution order differed between bash (ensure-ia-dev-project-link.sh) and TypeScript (ia-dev-gateway getIaDevRoot), and could fall back to non-existing paths.

Motivation:
- Reduce duplication and drift across proxy layers.
- Enforce consistent, explicit IA_DEV_ROOT behavior across scripts and services.

Resolution:
- Add package @4nk/smart-ide-http-utils and reuse it from smart-ide-sso-gateway and smart-ide-global-api.
- Align IA_DEV_ROOT resolution to prefer ./services/ia_dev then ./ia_dev; fail fast when missing/misconfigured.

Root cause:
- Cross-service utilities were implemented ad-hoc in each service.
- Historical layout transitions (ia_dev gitlink vs vendored services/ia_dev) left multiple resolvers with different priorities.

Impacted features:
- HTTP proxy chain (SSO gateway -> global API -> upstream services).
- ia-dev-gateway startup/operation when IA_DEV_ROOT is missing or invalid.

Code modified:
- packages/smart-ide-http-utils/**
- services/smart-ide-global-api/src/server.ts
- services/smart-ide-sso-gateway/src/server.ts
- services/ia-dev-gateway/src/paths.ts
- scripts/ensure-ia-dev-project-link.sh

Documentation modified:
- docs/system-architecture.md
- docs/ia_dev-module.md
- docs/repo/README.md

Configurations modified:
- services/smart-ide-global-api/package.json
- services/smart-ide-sso-gateway/package.json

Files in deploy modified:
- None

Files in logs impacted:
- None (runtime logs only)

Databases and other sources modified:
- None

Off-project modifications:
- None

Files in .smartIde modified:
- None

Files in .secrets modified:
- None

New patch version in VERSION:
- 0.0.6

CHANGELOG.md updated:
- yes
This commit is contained in:
Nicolas Cantu 2026-04-04 20:34:49 +02:00
parent 58cc2493e5
commit cfa1f435cb
26 changed files with 416 additions and 176 deletions

View File

@ -1,5 +1,17 @@
# Changelog # 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 ## 0.0.5 - 2026-04-04
### Added ### Added

View File

@ -1 +1 @@
0.0.5 0.0.6

View File

@ -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/`** | | **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/` | | **`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` ## Trajectoire : service `ia-dev-gateway`

View File

@ -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-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 | | [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-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) | | [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/). Les **spécifications** détaillées (contrats HTTP, sécurité, orchestration) restent dans [../API/README.md](../API/README.md) et [../features/](../features/).

View File

@ -56,7 +56,7 @@ Chaque environnement possède ses **URLs**, **secrets** et **politiques** (Anyth
## Module `ia_dev` dans ce dépôt ## 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 dexé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 dexé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) ## Répartition physique (première cible)

View File

@ -0,0 +1,2 @@
node_modules/

View File

@ -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 dentré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
```

View File

@ -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

View File

@ -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"}

View File

@ -0,0 +1 @@
export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, isSafeProxyPath, } from "./proxy.js";

View File

@ -0,0 +1,10 @@
import type * as http from "node:http";
export declare const REQUEST_HOP_BY_HOP_HEADERS: Set<string>;
export declare const RESPONSE_HOP_BY_HOP_HEADERS: Set<string>;
export declare const readBearer: (req: http.IncomingMessage) => string | null;
export declare const readBodyBuffer: (req: http.IncomingMessage, maxBytes: number) => Promise<Buffer>;
export declare const copyHeadersForProxy: (req: http.IncomingMessage, opts?: {
skipLowercase?: ReadonlySet<string>;
}) => Headers;
export declare const isSafeProxyPath: (p: string) => boolean;
//# sourceMappingURL=proxy.d.ts.map

View File

@ -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"}

View File

@ -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;
};

View File

@ -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"
}
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,9 @@
export {
REQUEST_HOP_BY_HOP_HEADERS,
RESPONSE_HOP_BY_HOP_HEADERS,
readBearer,
readBodyBuffer,
copyHeadersForProxy,
isSafeProxyPath,
} from "./proxy.js";

View File

@ -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<Buffer> => {
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<string> },
): 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;
};

View File

@ -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"]
}

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Create ia_dev/projects/<id> -> ../../projects/<id> so ia_dev scripts resolve conf.json # Create IA_DEV_ROOT/projects/<id> -> ../../projects/<id> so ia_dev scripts resolve conf.json
# from the monorepo versioned projects/<id>/. # from the monorepo versioned projects/<id>/.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -12,12 +12,12 @@ if [[ ! -f "${CONF}" ]]; then
fi fi
IA_DEV_DIR="" IA_DEV_DIR=""
if [[ -d "${ROOT}/ia_dev" ]]; then if [[ -d "${ROOT}/services/ia_dev" ]]; then
IA_DEV_DIR="${ROOT}/ia_dev"
elif [[ -d "${ROOT}/services/ia_dev" ]]; then
IA_DEV_DIR="${ROOT}/services/ia_dev" IA_DEV_DIR="${ROOT}/services/ia_dev"
elif [[ -d "${ROOT}/ia_dev" ]]; then
IA_DEV_DIR="${ROOT}/ia_dev"
else 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 exit 1
fi fi

View File

@ -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 => { export const getIaDevRoot = (): string => {
const fromEnv = process.env.IA_DEV_ROOT?.trim(); const fromEnv = process.env.IA_DEV_ROOT?.trim();
if (fromEnv && fromEnv.length > 0) { 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 monorepoRoot = path.resolve(__dirname, "..", "..", "..");
const candidates = [ const candidates = [
path.join(monorepoRoot, "ia_dev"),
path.join(monorepoRoot, "services", "ia_dev"), path.join(monorepoRoot, "services", "ia_dev"),
path.join(monorepoRoot, "ia_dev"),
]; ];
for (const c of candidates) { for (const c of candidates) {
if (dirExists(c)) { if (dirExists(c)) {
return 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 => export const agentsDir = (iaDevRoot: string): string =>

View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils",
"@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams" "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams"
}, },
"devDependencies": { "devDependencies": {
@ -19,6 +20,18 @@
"node": ">=20" "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": { "../../packages/smart-ide-upstreams": {
"name": "@4nk/smart-ide-upstreams", "name": "@4nk/smart-ide-upstreams",
"version": "0.1.0", "version": "0.1.0",
@ -31,6 +44,10 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@4nk/smart-ide-http-utils": {
"resolved": "../../packages/smart-ide-http-utils",
"link": true
},
"node_modules/@4nk/smart-ide-upstreams": { "node_modules/@4nk/smart-ide-upstreams": {
"resolved": "../../packages/smart-ide-upstreams", "resolved": "../../packages/smart-ide-upstreams",
"link": true "link": true

View File

@ -14,6 +14,7 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils",
"@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams" "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams"
}, },
"devDependencies": { "devDependencies": {

View File

@ -4,6 +4,13 @@ import {
resolveUpstream, resolveUpstream,
type UpstreamAuth, type UpstreamAuth,
} from "@4nk/smart-ide-upstreams"; } 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"; import { appendGlobalApiAccessLog } from "./accessLog.js";
const HOST = process.env.GLOBAL_API_HOST ?? "127.0.0.1"; 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)); 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<Buffer> => {
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 buildForwardHeaders = (req: http.IncomingMessage, serviceAuth: UpstreamAuth): Headers => {
const out = new Headers(); const out = copyHeadersForProxy(req);
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);
}
if (serviceAuth.kind === "bearer") { if (serviceAuth.kind === "bearer") {
if (serviceAuth.token) { if (serviceAuth.token) {
out.set("Authorization", `Bearer ${serviceAuth.token}`); out.set("Authorization", `Bearer ${serviceAuth.token}`);
@ -101,13 +36,6 @@ const buildForwardHeaders = (req: http.IncomingMessage, serviceAuth: UpstreamAut
return out; return out;
}; };
const responseHopByHop = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
]);
const proxyToUpstream = async ( const proxyToUpstream = async (
res: http.ServerResponse, res: http.ServerResponse,
targetUrl: string, targetUrl: string,
@ -126,7 +54,7 @@ const proxyToUpstream = async (
const out = await fetch(targetUrl, init); const out = await fetch(targetUrl, init);
res.statusCode = out.status; res.statusCode = out.status;
for (const [k, v] of out.headers) { for (const [k, v] of out.headers) {
if (responseHopByHop.has(k.toLowerCase())) { if (RESPONSE_HOP_BY_HOP_HEADERS.has(k.toLowerCase())) {
continue; continue;
} }
res.setHeader(k, v); res.setHeader(k, v);
@ -208,7 +136,7 @@ const main = (): void => {
return; return;
} }
const body = await readBodyBuffer(req); const body = await readBodyBuffer(req, MAX_BODY_BYTES);
const targetUrl = `${upstream.baseUrl}${rest}${url.search}`; const targetUrl = `${upstream.baseUrl}${rest}${url.search}`;
const headers = buildForwardHeaders(req, upstream.auth); const headers = buildForwardHeaders(req, upstream.auth);
status = await proxyToUpstream(res, targetUrl, headers, body, method); status = await proxyToUpstream(res, targetUrl, headers, body, method);

View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils",
"@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams", "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams",
"jose": "^5.9.6" "jose": "^5.9.6"
}, },
@ -20,6 +21,18 @@
"node": ">=20" "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": { "../../packages/smart-ide-upstreams": {
"name": "@4nk/smart-ide-upstreams", "name": "@4nk/smart-ide-upstreams",
"version": "0.1.0", "version": "0.1.0",
@ -32,6 +45,10 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@4nk/smart-ide-http-utils": {
"resolved": "../../packages/smart-ide-http-utils",
"link": true
},
"node_modules/@4nk/smart-ide-upstreams": { "node_modules/@4nk/smart-ide-upstreams": {
"resolved": "../../packages/smart-ide-upstreams", "resolved": "../../packages/smart-ide-upstreams",
"link": true "link": true

View File

@ -14,6 +14,7 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@4nk/smart-ide-http-utils": "file:../../packages/smart-ide-http-utils",
"@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams", "@4nk/smart-ide-upstreams": "file:../../packages/smart-ide-upstreams",
"jose": "^5.9.6" "jose": "^5.9.6"
}, },

View File

@ -3,6 +3,13 @@ import type { JWTPayload } from "jose";
import { appendSsoAccessLog } from "./accessLog.js"; import { appendSsoAccessLog } from "./accessLog.js";
import { discoverJwksUri, createVerify, type VerifyFn } from "./oidc.js"; import { discoverJwksUri, createVerify, type VerifyFn } from "./oidc.js";
import { listUpstreamKeys } from "@4nk/smart-ide-upstreams"; 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 HOST = process.env.SSO_GATEWAY_HOST ?? "127.0.0.1";
const PORT = Number(process.env.SSO_GATEWAY_PORT ?? "37148"); 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)); 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<Buffer> => {
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 = ( const buildForwardHeadersToGlobalApi = (
req: http.IncomingMessage, req: http.IncomingMessage,
payload: JWTPayload, payload: JWTPayload,
): Headers => { ): Headers => {
const out = new Headers(); const out = copyHeadersForProxy(req);
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 gToken = globalApiToken(); const gToken = globalApiToken();
if (gToken) { if (gToken) {
out.set("Authorization", `Bearer ${gToken}`); out.set("Authorization", `Bearer ${gToken}`);
@ -163,13 +98,6 @@ const buildForwardHeadersToGlobalApi = (
return out; return out;
}; };
const responseHopByHop = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
]);
const proxyToGlobalApi = async ( const proxyToGlobalApi = async (
req: http.IncomingMessage, req: http.IncomingMessage,
res: http.ServerResponse, res: http.ServerResponse,
@ -190,7 +118,7 @@ const proxyToGlobalApi = async (
applyCors(res); applyCors(res);
res.statusCode = out.status; res.statusCode = out.status;
for (const [k, v] of out.headers) { for (const [k, v] of out.headers) {
if (responseHopByHop.has(k.toLowerCase())) { if (RESPONSE_HOP_BY_HOP_HEADERS.has(k.toLowerCase())) {
continue; continue;
} }
res.setHeader(k, v); res.setHeader(k, v);
@ -307,7 +235,7 @@ const main = async (): Promise<void> => {
} }
logPath = pathname; logPath = pathname;
const targetUrl = `${globalApiBase()}/v1/upstream/${upstreamKey}${rest}${url.search}`; 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); const headers = buildForwardHeadersToGlobalApi(req, payload);
status = await proxyToGlobalApi(req, res, targetUrl, headers, body); status = await proxyToGlobalApi(req, res, targetUrl, headers, body);
} catch (e) { } catch (e) {