diff --git a/CHANGELOG.md b/CHANGELOG.md index 6045852..d9b2c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.0.7 - 2026-04-04 + +### Changed + +- `@4nk/smart-ide-http-utils`: add `copyOutgoingHeadersForProxy()` for Node `http.request` proxies. +- `claw-harness-proxy`: validate request-target to prevent SSRF (reject absolute URLs and `//`), reuse safe proxy path checks and avoid forwarding client `Authorization`. +- Docs: align default `IA_DEV_ROOT` resolution order (`./services/ia_dev` then `./ia_dev`). + ## 0.0.6 - 2026-04-04 ### Added diff --git a/VERSION b/VERSION index 1750564..5a5831a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.6 +0.0.7 diff --git a/docs/repo/ia-dev-smart-ide-integration.md b/docs/repo/ia-dev-smart-ide-integration.md index 2c3e4ce..2afe61a 100644 --- a/docs/repo/ia-dev-smart-ide-integration.md +++ b/docs/repo/ia-dev-smart-ide-integration.md @@ -2,7 +2,7 @@ Le répertoire **`services/ia_dev/`** dans le monorepo **smart_ide** est le **module agents / déploiement / ticketing** (équivalent historique du dépôt [4nk/ia_dev](https://git.4nkweb.com/4nk/ia_dev.git)). Il est **versionné dans ce dépôt**. -La racine du checkout `ia_dev` est traitée comme **`IA_DEV_ROOT`** par les scripts et services. Par défaut, les services tentent `./ia_dev` puis `./services/ia_dev` si `IA_DEV_ROOT` n’est pas défini. +La racine du checkout `ia_dev` est traitée comme **`IA_DEV_ROOT`** par les scripts et services. Par défaut, les services tentent `./services/ia_dev` puis `./ia_dev` si `IA_DEV_ROOT` n’est pas défini. ## Rôle diff --git a/packages/smart-ide-http-utils/dist/index.d.ts b/packages/smart-ide-http-utils/dist/index.d.ts index 6ad3997..e6117df 100644 --- a/packages/smart-ide-http-utils/dist/index.d.ts +++ b/packages/smart-ide-http-utils/dist/index.d.ts @@ -1,2 +1,2 @@ -export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, isSafeProxyPath, } from "./proxy.js"; +export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, copyOutgoingHeadersForProxy, 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 index 5f44206..3bd19f9 100644 --- a/packages/smart-ide-http-utils/dist/index.d.ts.map +++ b/packages/smart-ide-http-utils/dist/index.d.ts.map @@ -1 +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 +{"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,2BAA2B,EAC3B,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 index 23ede23..aa877ad 100644 --- a/packages/smart-ide-http-utils/dist/index.js +++ b/packages/smart-ide-http-utils/dist/index.js @@ -1 +1 @@ -export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, isSafeProxyPath, } from "./proxy.js"; +export { REQUEST_HOP_BY_HOP_HEADERS, RESPONSE_HOP_BY_HOP_HEADERS, readBearer, readBodyBuffer, copyHeadersForProxy, copyOutgoingHeadersForProxy, 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 index 1eb77df..b6ed6bd 100644 --- a/packages/smart-ide-http-utils/dist/proxy.d.ts +++ b/packages/smart-ide-http-utils/dist/proxy.d.ts @@ -6,5 +6,8 @@ export declare const readBodyBuffer: (req: http.IncomingMessage, maxBytes: numbe export declare const copyHeadersForProxy: (req: http.IncomingMessage, opts?: { skipLowercase?: ReadonlySet; }) => Headers; +export declare const copyOutgoingHeadersForProxy: (req: http.IncomingMessage, opts?: { + skipLowercase?: ReadonlySet; +}) => http.OutgoingHttpHeaders; 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 index 7fc84e6..2ccd807 100644 --- a/packages/smart-ide-http-utils/dist/proxy.d.ts.map +++ b/packages/smart-ide-http-utils/dist/proxy.d.ts.map @@ -1 +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 +{"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,2BAA2B,GACtC,KAAK,IAAI,CAAC,eAAe,EACzB,OAAO;IAAE,aAAa,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CAAE,KAC7C,IAAI,CAAC,mBAmBP,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 index dd78957..96a52f1 100644 --- a/packages/smart-ide-http-utils/dist/proxy.js +++ b/packages/smart-ide-http-utils/dist/proxy.js @@ -53,6 +53,26 @@ export const copyHeadersForProxy = (req, opts) => { } return out; }; +export const copyOutgoingHeadersForProxy = (req, opts) => { + const out = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (v === undefined) { + 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[k] = v; + } + return out; +}; export const isSafeProxyPath = (p) => { if (!p.startsWith("/")) { return false; diff --git a/packages/smart-ide-http-utils/src/index.ts b/packages/smart-ide-http-utils/src/index.ts index d2b54c4..c9cf0d3 100644 --- a/packages/smart-ide-http-utils/src/index.ts +++ b/packages/smart-ide-http-utils/src/index.ts @@ -4,6 +4,7 @@ export { readBearer, readBodyBuffer, copyHeadersForProxy, + copyOutgoingHeadersForProxy, isSafeProxyPath, } from "./proxy.js"; diff --git a/packages/smart-ide-http-utils/src/proxy.ts b/packages/smart-ide-http-utils/src/proxy.ts index ca8ac7d..ad714e5 100644 --- a/packages/smart-ide-http-utils/src/proxy.ts +++ b/packages/smart-ide-http-utils/src/proxy.ts @@ -66,6 +66,30 @@ export const copyHeadersForProxy = ( return out; }; +export const copyOutgoingHeadersForProxy = ( + req: http.IncomingMessage, + opts?: { skipLowercase?: ReadonlySet }, +): http.OutgoingHttpHeaders => { + const out: http.OutgoingHttpHeaders = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (v === undefined) { + 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[k] = v; + } + return out; +}; + export const isSafeProxyPath = (p: string): boolean => { if (!p.startsWith("/")) { return false; diff --git a/services/claw-harness-api/proxy/package-lock.json b/services/claw-harness-api/proxy/package-lock.json index 8adfb16..f2fb4af 100644 --- a/services/claw-harness-api/proxy/package-lock.json +++ b/services/claw-harness-api/proxy/package-lock.json @@ -8,6 +8,9 @@ "name": "@4nk/claw-harness-proxy", "version": "0.1.0", "license": "MIT", + "dependencies": { + "@4nk/smart-ide-http-utils": "file:../../../packages/smart-ide-http-utils" + }, "devDependencies": { "@types/node": "^20.11.0", "typescript": "^5.3.3" @@ -16,6 +19,22 @@ "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" + } + }, + "node_modules/@4nk/smart-ide-http-utils": { + "resolved": "../../../packages/smart-ide-http-utils", + "link": true + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", diff --git a/services/claw-harness-api/proxy/package.json b/services/claw-harness-api/proxy/package.json index 8c9fce2..14d1f72 100644 --- a/services/claw-harness-api/proxy/package.json +++ b/services/claw-harness-api/proxy/package.json @@ -13,6 +13,9 @@ "engines": { "node": ">=20" }, + "dependencies": { + "@4nk/smart-ide-http-utils": "file:../../../packages/smart-ide-http-utils" + }, "devDependencies": { "@types/node": "^20.11.0", "typescript": "^5.3.3" diff --git a/services/claw-harness-api/proxy/src/server.ts b/services/claw-harness-api/proxy/src/server.ts index b26ef70..fcb5768 100644 --- a/services/claw-harness-api/proxy/src/server.ts +++ b/services/claw-harness-api/proxy/src/server.ts @@ -1,9 +1,10 @@ import * as http from "node:http"; import * as https from "node:https"; import { URL } from "node:url"; +import { copyOutgoingHeadersForProxy, isSafeProxyPath } from "@4nk/smart-ide-http-utils"; import { readExpectedToken, requireBearer } from "./auth.js"; -const HOP_BY_HOP = new Set([ +const RESPONSE_HOP_BY_HOP = new Set([ "connection", "keep-alive", "proxy-authenticate", @@ -22,23 +23,6 @@ const readUpstreamBase = (): URL => { return new URL(raw.endsWith("/") ? raw.slice(0, -1) : raw); }; -const forwardHeaders = ( - req: http.IncomingMessage, -): http.OutgoingHttpHeaders => { - const out: http.OutgoingHttpHeaders = {}; - for (const [k, v] of Object.entries(req.headers)) { - if (v === undefined) { - continue; - } - const key = k.toLowerCase(); - if (HOP_BY_HOP.has(key) || key === "host") { - continue; - } - out[k] = v; - } - return out; -}; - const HOST = process.env.CLAW_PROXY_HOST ?? "127.0.0.1"; const PORT = Number(process.env.CLAW_PROXY_PORT ?? "37142"); @@ -71,15 +55,30 @@ const main = (): void => { } const urlPath = req.url ?? "/"; - const target = new URL(urlPath, `${upstreamBase.origin}/`); + if (!urlPath.startsWith("/") || urlPath.startsWith("//")) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Invalid proxy path" })); + return; + } + const parsed = new URL(urlPath, "http://localhost"); + if (!isSafeProxyPath(parsed.pathname)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Invalid proxy path" })); + return; + } + const target = new URL(`${parsed.pathname}${parsed.search}`, `${upstreamBase.origin}/`); + if (target.origin !== upstreamBase.origin) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Invalid proxy path" })); + return; + } const isHttps = target.protocol === "https:"; const lib = isHttps ? https : http; const defaultPort = isHttps ? 443 : 80; - const port = - target.port !== "" ? Number(target.port) : defaultPort; + const port = target.port !== "" ? Number(target.port) : defaultPort; - const headers = forwardHeaders(req); + const headers = copyOutgoingHeadersForProxy(req); const preq = lib.request( { @@ -90,22 +89,41 @@ const main = (): void => { headers, }, (pres) => { - const ph = { ...pres.headers }; + const ph: http.OutgoingHttpHeaders = {}; + for (const [k, v] of Object.entries(pres.headers)) { + if (v === undefined) { + continue; + } + if (RESPONSE_HOP_BY_HOP.has(k.toLowerCase())) { + continue; + } + ph[k] = v; + } res.writeHead(pres.statusCode ?? 502, ph); pres.pipe(res); }, ); preq.on("error", (err) => { - res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ error: err.message })); + console.error(`claw-harness-proxy: upstream request error: ${err.message}`); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Upstream request failed" })); + return; + } + res.end(); }); req.pipe(preq); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify({ error: msg })); + console.error(`claw-harness-proxy: request failed: ${msg}`); + if (!res.headersSent) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Request failed" })); + return; + } + res.end(); } })(); });