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:
parent
cfa1f435cb
commit
255acbaf97
@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 0.0.6 - 2026-04-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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**.
|
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
|
## Rôle
|
||||||
|
|
||||||
|
|||||||
@ -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
|
//# sourceMappingURL=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"}
|
{"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"}
|
||||||
2
packages/smart-ide-http-utils/dist/index.js
vendored
2
packages/smart-ide-http-utils/dist/index.js
vendored
@ -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";
|
||||||
|
|||||||
@ -6,5 +6,8 @@ export declare const readBodyBuffer: (req: http.IncomingMessage, maxBytes: numbe
|
|||||||
export declare const copyHeadersForProxy: (req: http.IncomingMessage, opts?: {
|
export declare const copyHeadersForProxy: (req: http.IncomingMessage, opts?: {
|
||||||
skipLowercase?: ReadonlySet<string>;
|
skipLowercase?: ReadonlySet<string>;
|
||||||
}) => Headers;
|
}) => Headers;
|
||||||
|
export declare const copyOutgoingHeadersForProxy: (req: http.IncomingMessage, opts?: {
|
||||||
|
skipLowercase?: ReadonlySet<string>;
|
||||||
|
}) => http.OutgoingHttpHeaders;
|
||||||
export declare const isSafeProxyPath: (p: string) => boolean;
|
export declare const isSafeProxyPath: (p: string) => boolean;
|
||||||
//# sourceMappingURL=proxy.d.ts.map
|
//# sourceMappingURL=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"}
|
{"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"}
|
||||||
20
packages/smart-ide-http-utils/dist/proxy.js
vendored
20
packages/smart-ide-http-utils/dist/proxy.js
vendored
@ -53,6 +53,26 @@ export const copyHeadersForProxy = (req, opts) => {
|
|||||||
}
|
}
|
||||||
return out;
|
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) => {
|
export const isSafeProxyPath = (p) => {
|
||||||
if (!p.startsWith("/")) {
|
if (!p.startsWith("/")) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export {
|
|||||||
readBearer,
|
readBearer,
|
||||||
readBodyBuffer,
|
readBodyBuffer,
|
||||||
copyHeadersForProxy,
|
copyHeadersForProxy,
|
||||||
|
copyOutgoingHeadersForProxy,
|
||||||
isSafeProxyPath,
|
isSafeProxyPath,
|
||||||
} from "./proxy.js";
|
} from "./proxy.js";
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,30 @@ export const copyHeadersForProxy = (
|
|||||||
return out;
|
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 => {
|
export const isSafeProxyPath = (p: string): boolean => {
|
||||||
if (!p.startsWith("/")) {
|
if (!p.startsWith("/")) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
19
services/claw-harness-api/proxy/package-lock.json
generated
19
services/claw-harness-api/proxy/package-lock.json
generated
@ -8,6 +8,9 @@
|
|||||||
"name": "@4nk/claw-harness-proxy",
|
"name": "@4nk/claw-harness-proxy",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@4nk/smart-ide-http-utils": "file:../../../packages/smart-ide-http-utils"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
@ -16,6 +19,22 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@4nk/smart-ide-http-utils": {
|
||||||
|
"resolved": "../../../packages/smart-ide-http-utils",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
|
|||||||
@ -13,6 +13,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@4nk/smart-ide-http-utils": "file:../../../packages/smart-ide-http-utils"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import * as http from "node:http";
|
import * as http from "node:http";
|
||||||
import * as https from "node:https";
|
import * as https from "node:https";
|
||||||
import { URL } from "node:url";
|
import { URL } from "node:url";
|
||||||
|
import { copyOutgoingHeadersForProxy, isSafeProxyPath } from "@4nk/smart-ide-http-utils";
|
||||||
import { readExpectedToken, requireBearer } from "./auth.js";
|
import { readExpectedToken, requireBearer } from "./auth.js";
|
||||||
|
|
||||||
const HOP_BY_HOP = new Set([
|
const RESPONSE_HOP_BY_HOP = new Set([
|
||||||
"connection",
|
"connection",
|
||||||
"keep-alive",
|
"keep-alive",
|
||||||
"proxy-authenticate",
|
"proxy-authenticate",
|
||||||
@ -22,23 +23,6 @@ const readUpstreamBase = (): URL => {
|
|||||||
return new URL(raw.endsWith("/") ? raw.slice(0, -1) : raw);
|
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 HOST = process.env.CLAW_PROXY_HOST ?? "127.0.0.1";
|
||||||
const PORT = Number(process.env.CLAW_PROXY_PORT ?? "37142");
|
const PORT = Number(process.env.CLAW_PROXY_PORT ?? "37142");
|
||||||
|
|
||||||
@ -71,15 +55,30 @@ const main = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const urlPath = req.url ?? "/";
|
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 isHttps = target.protocol === "https:";
|
||||||
const lib = isHttps ? https : http;
|
const lib = isHttps ? https : http;
|
||||||
const defaultPort = isHttps ? 443 : 80;
|
const defaultPort = isHttps ? 443 : 80;
|
||||||
const port =
|
const port = target.port !== "" ? Number(target.port) : defaultPort;
|
||||||
target.port !== "" ? Number(target.port) : defaultPort;
|
|
||||||
|
|
||||||
const headers = forwardHeaders(req);
|
const headers = copyOutgoingHeadersForProxy(req);
|
||||||
|
|
||||||
const preq = lib.request(
|
const preq = lib.request(
|
||||||
{
|
{
|
||||||
@ -90,22 +89,41 @@ const main = (): void => {
|
|||||||
headers,
|
headers,
|
||||||
},
|
},
|
||||||
(pres) => {
|
(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);
|
res.writeHead(pres.statusCode ?? 502, ph);
|
||||||
pres.pipe(res);
|
pres.pipe(res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
preq.on("error", (err) => {
|
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.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);
|
req.pipe(preq);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(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.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();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user