fix: harden claw-harness-proxy and complete HTTP utils centralization (0.0.7)

Initial state:
- claw-harness-proxy accepted absolute-form / scheme-relative request targets, allowing proxying to arbitrary hosts.
- claw-harness-proxy forwarded client Authorization headers upstream.
- @4nk/smart-ide-http-utils did not provide helpers for Node http.request-based proxies.
- docs/repo/ia-dev-smart-ide-integration.md still documented the old IA_DEV_ROOT default resolution order.

Motivation:
- Ensure safe proxy behavior for every HTTP relay in the monorepo.
- Keep the IA_DEV_ROOT contract consistent across code and docs.

Resolution:
- Extend @4nk/smart-ide-http-utils with copyOutgoingHeadersForProxy() for http.request.
- Harden claw-harness-proxy: reject absolute URLs and '//' targets, validate safe proxy paths, avoid forwarding Authorization, and avoid leaking internal error details.
- Align ia-dev-smart-ide-integration doc default order to ./services/ia_dev then ./ia_dev.

Root cause:
- Proxy implementation treated req.url as a URL to be resolved and allowed absolute inputs.
- Cross-proxy utilities were only implemented for fetch-based proxies.

Impacted features:
- claw-harness-proxy HTTP forwarding.
- shared HTTP utility package.
- IA_DEV_ROOT documentation.

Code modified:
- packages/smart-ide-http-utils/src/* + dist/*
- services/claw-harness-api/proxy/src/server.ts

Documentation modified:
- docs/repo/ia-dev-smart-ide-integration.md
- CHANGELOG.md

Configurations modified:
- services/claw-harness-api/proxy/package.json

Files in deploy modified:
- None

Files in logs impacted:
- None

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

CHANGELOG.md updated:
- yes
This commit is contained in:
Nicolas Cantu 2026-04-04 20:48:11 +02:00
parent cfa1f435cb
commit 255acbaf97
14 changed files with 129 additions and 33 deletions

View File

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

View File

@ -1 +1 @@
0.0.6
0.0.7

View File

@ -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` nest 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` nest pas défini.
## Rôle

View File

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

View File

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

View File

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

View File

@ -6,5 +6,8 @@ export declare const readBodyBuffer: (req: http.IncomingMessage, maxBytes: numbe
export declare const copyHeadersForProxy: (req: http.IncomingMessage, opts?: {
skipLowercase?: ReadonlySet<string>;
}) => Headers;
export declare const copyOutgoingHeadersForProxy: (req: http.IncomingMessage, opts?: {
skipLowercase?: ReadonlySet<string>;
}) => http.OutgoingHttpHeaders;
export declare const isSafeProxyPath: (p: string) => boolean;
//# sourceMappingURL=proxy.d.ts.map

View File

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

View File

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

View File

@ -4,6 +4,7 @@ export {
readBearer,
readBodyBuffer,
copyHeadersForProxy,
copyOutgoingHeadersForProxy,
isSafeProxyPath,
} from "./proxy.js";

View File

@ -66,6 +66,30 @@ export const copyHeadersForProxy = (
return out;
};
export const copyOutgoingHeadersForProxy = (
req: http.IncomingMessage,
opts?: { skipLowercase?: ReadonlySet<string> },
): 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;

View File

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

View File

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

View File

@ -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) => {
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: err.message }));
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);
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: msg }));
res.end(JSON.stringify({ error: "Request failed" }));
return;
}
res.end();
}
})();
});