security: config include path hardening (NUL + length)#43576
security: config include path hardening (NUL + length)#43576maweibin wants to merge 1 commit intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR adds input-validation hardening to the
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/config/includes.ts
Line: 198-216
Comment:
**Off-by-one allows exactly 4096-char absolute paths to bypass both checks**
Both length guards use strict greater-than (`>`), so a path whose character count is *exactly* `MAX_INCLUDE_PATH_LENGTH` (4096) passes them both. For a **relative** path this is fine because resolution prepends `configDir`, making the resulting path longer. But for an **absolute** path that is already 4096 characters long, normalization never increases the length, so both checks read `4096 > 4096 → false` and the path reaches `fs.realpathSync` / `fs.readFileSync`.
On Linux `PATH_MAX = 4096` *includes* the null terminator, so paths of exactly 4096 characters can trigger `ENAMETOOLONG`. The stated goal is "capping at 4096", which implies paths of that length should be rejected.
Consider using `>=` on both guards (or keeping the constant at 4096 but treating it as an exclusive upper bound in the error message):
```suggestion
if (includePath.length > MAX_INCLUDE_PATH_LENGTH) {
```
```suggestion
if (normalized.length > MAX_INCLUDE_PATH_LENGTH) {
```
These two lines should be changed to `>=` to correctly exclude 4096-char paths:
```ts
if (includePath.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
// ...
if (normalized.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/config/includes.test.ts
Line: 614-615
Comment:
**"atLimit" case tests resolved-path limit, not raw-path limit — absolute paths at the limit are untested**
`"b".repeat(MAX_INCLUDE_PATH_LENGTH)` (exactly 4096 `b`s) is a *relative* path, so it gets resolved to `configDir + "/" + "b".repeat(4096)`, which is always longer than 4096 characters and is caught by the *second* (post-normalize) check. The *first* check (`includePath.length > MAX_INCLUDE_PATH_LENGTH`) is never exercised for boundary value 4096.
More importantly, an **absolute** path of exactly 4096 characters is never covered. For that case, the resolved path equals the input, normalization leaves it unchanged, and both guards evaluate to `4096 > 4096 → false` — so the path passes through to the OS. Consider adding a test:
```ts
// absolute path at exactly the limit: should also be rejected
const absoluteAtLimit = "/" + "a".repeat(MAX_INCLUDE_PATH_LENGTH - 1); // length == 4096
expectResolveIncludeError(
() => resolve({ $include: absoluteAtLimit }, {}),
/maximum length/,
);
```
If the boundary is intentionally exclusive (i.e., 4096-char paths are permitted), the test name and PR description should be updated to reflect that.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: f543dae |
src/config/includes.ts
Outdated
| if (includePath.length > MAX_INCLUDE_PATH_LENGTH) { | ||
| throw new ConfigIncludeError( | ||
| `Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, | ||
| includePath, | ||
| ); | ||
| } | ||
|
|
||
| const configDir = path.dirname(this.basePath); | ||
| const resolved = path.isAbsolute(includePath) | ||
| ? includePath | ||
| : path.resolve(configDir, includePath); | ||
| const normalized = path.normalize(resolved); | ||
|
|
||
| if (normalized.length > MAX_INCLUDE_PATH_LENGTH) { | ||
| throw new ConfigIncludeError( | ||
| `Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, | ||
| includePath, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Off-by-one allows exactly 4096-char absolute paths to bypass both checks
Both length guards use strict greater-than (>), so a path whose character count is exactly MAX_INCLUDE_PATH_LENGTH (4096) passes them both. For a relative path this is fine because resolution prepends configDir, making the resulting path longer. But for an absolute path that is already 4096 characters long, normalization never increases the length, so both checks read 4096 > 4096 → false and the path reaches fs.realpathSync / fs.readFileSync.
On Linux PATH_MAX = 4096 includes the null terminator, so paths of exactly 4096 characters can trigger ENAMETOOLONG. The stated goal is "capping at 4096", which implies paths of that length should be rejected.
Consider using >= on both guards (or keeping the constant at 4096 but treating it as an exclusive upper bound in the error message):
| if (includePath.length > MAX_INCLUDE_PATH_LENGTH) { | |
| throw new ConfigIncludeError( | |
| `Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, | |
| includePath, | |
| ); | |
| } | |
| const configDir = path.dirname(this.basePath); | |
| const resolved = path.isAbsolute(includePath) | |
| ? includePath | |
| : path.resolve(configDir, includePath); | |
| const normalized = path.normalize(resolved); | |
| if (normalized.length > MAX_INCLUDE_PATH_LENGTH) { | |
| throw new ConfigIncludeError( | |
| `Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, | |
| includePath, | |
| ); | |
| } | |
| if (includePath.length > MAX_INCLUDE_PATH_LENGTH) { |
| if (includePath.length > MAX_INCLUDE_PATH_LENGTH) { | |
| throw new ConfigIncludeError( | |
| `Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, | |
| includePath, | |
| ); | |
| } | |
| const configDir = path.dirname(this.basePath); | |
| const resolved = path.isAbsolute(includePath) | |
| ? includePath | |
| : path.resolve(configDir, includePath); | |
| const normalized = path.normalize(resolved); | |
| if (normalized.length > MAX_INCLUDE_PATH_LENGTH) { | |
| throw new ConfigIncludeError( | |
| `Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`, | |
| includePath, | |
| ); | |
| } | |
| if (normalized.length > MAX_INCLUDE_PATH_LENGTH) { |
These two lines should be changed to >= to correctly exclude 4096-char paths:
if (includePath.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
// ...
if (normalized.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/includes.ts
Line: 198-216
Comment:
**Off-by-one allows exactly 4096-char absolute paths to bypass both checks**
Both length guards use strict greater-than (`>`), so a path whose character count is *exactly* `MAX_INCLUDE_PATH_LENGTH` (4096) passes them both. For a **relative** path this is fine because resolution prepends `configDir`, making the resulting path longer. But for an **absolute** path that is already 4096 characters long, normalization never increases the length, so both checks read `4096 > 4096 → false` and the path reaches `fs.realpathSync` / `fs.readFileSync`.
On Linux `PATH_MAX = 4096` *includes* the null terminator, so paths of exactly 4096 characters can trigger `ENAMETOOLONG`. The stated goal is "capping at 4096", which implies paths of that length should be rejected.
Consider using `>=` on both guards (or keeping the constant at 4096 but treating it as an exclusive upper bound in the error message):
```suggestion
if (includePath.length > MAX_INCLUDE_PATH_LENGTH) {
```
```suggestion
if (normalized.length > MAX_INCLUDE_PATH_LENGTH) {
```
These two lines should be changed to `>=` to correctly exclude 4096-char paths:
```ts
if (includePath.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
// ...
if (normalized.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
```
How can I resolve this? If you propose a fix, please make it concise.| const atLimit = "b".repeat(MAX_INCLUDE_PATH_LENGTH); | ||
| expectResolveIncludeError(() => resolve({ $include: atLimit }, {}), /maximum length/); |
There was a problem hiding this comment.
"atLimit" case tests resolved-path limit, not raw-path limit — absolute paths at the limit are untested
"b".repeat(MAX_INCLUDE_PATH_LENGTH) (exactly 4096 bs) is a relative path, so it gets resolved to configDir + "/" + "b".repeat(4096), which is always longer than 4096 characters and is caught by the second (post-normalize) check. The first check (includePath.length > MAX_INCLUDE_PATH_LENGTH) is never exercised for boundary value 4096.
More importantly, an absolute path of exactly 4096 characters is never covered. For that case, the resolved path equals the input, normalization leaves it unchanged, and both guards evaluate to 4096 > 4096 → false — so the path passes through to the OS. Consider adding a test:
// absolute path at exactly the limit: should also be rejected
const absoluteAtLimit = "/" + "a".repeat(MAX_INCLUDE_PATH_LENGTH - 1); // length == 4096
expectResolveIncludeError(
() => resolve({ $include: absoluteAtLimit }, {}),
/maximum length/,
);If the boundary is intentionally exclusive (i.e., 4096-char paths are permitted), the test name and PR description should be updated to reflect that.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/includes.test.ts
Line: 614-615
Comment:
**"atLimit" case tests resolved-path limit, not raw-path limit — absolute paths at the limit are untested**
`"b".repeat(MAX_INCLUDE_PATH_LENGTH)` (exactly 4096 `b`s) is a *relative* path, so it gets resolved to `configDir + "/" + "b".repeat(4096)`, which is always longer than 4096 characters and is caught by the *second* (post-normalize) check. The *first* check (`includePath.length > MAX_INCLUDE_PATH_LENGTH`) is never exercised for boundary value 4096.
More importantly, an **absolute** path of exactly 4096 characters is never covered. For that case, the resolved path equals the input, normalization leaves it unchanged, and both guards evaluate to `4096 > 4096 → false` — so the path passes through to the OS. Consider adding a test:
```ts
// absolute path at exactly the limit: should also be rejected
const absoluteAtLimit = "/" + "a".repeat(MAX_INCLUDE_PATH_LENGTH - 1); // length == 4096
expectResolveIncludeError(
() => resolve({ $include: absoluteAtLimit }, {}),
/maximum length/,
);
```
If the boundary is intentionally exclusive (i.e., 4096-char paths are permitted), the test name and PR description should be updated to reflect that.
How can I resolve this? If you propose a fix, please make it concise.|
@steipete Hi, I’ve opened a small security hardening PR for config $include resolution. It rejects paths that contain null bytes and enforces a 4096-character limit (CWE-22 defense-in-depth). All changes are covered by tests and the config docs are updated. Would be great if you could review/merge when convenient. Thanks. |
f543dae to
865b471
Compare
Summary
This PR adds path hardening for the config
$includedirective (CWE-22 defense-in-depth). It does not introduce new features; it tightens validation so that invalid or abusive include paths are rejected early with clear errors instead of relying on platform-dependent or undefined behavior.Motivation
\0): On some platforms or when passed to native/fs APIs, a path containing a null byte can be interpreted as truncated (e.g."./a\0../../../etc/passwd"→"./a"), which could bypass path-traversal checks or cause inconsistent behavior. Rejecting NUL in include paths removes this ambiguity.PATH_MAX) keeps behavior predictable and avoids DoS-style inputs.Existing protections (path traversal and symlink checks) remain unchanged; this PR adds input validation before path resolution.
Changes
Code (
src/config/includes.ts)MAX_INCLUDE_PATH_LENGTH = 4096(exported for tests and possible reuse).resolvePath(includePath)(before anypath.resolve/path.normalize):includePathcontains\0→ throwConfigIncludeErrorwith message:"Include path must not contain null bytes".includePath.length > MAX_INCLUDE_PATH_LENGTH→ throwConfigIncludeErrorwith message:"Include path exceeds maximum length (4096 characters)".normalized = path.normalize(resolved), ifnormalized.length > MAX_INCLUDE_PATH_LENGTH→ throwConfigIncludeErrorwith message:"Resolved include path exceeds maximum length (4096 characters)".All new errors use the existing
ConfigIncludeErrortype and do not log or echo the raw path in the message (to avoid leaking NUL or very long strings).Tests (
src/config/includes.test.ts)MAX_INCLUDE_PATH_LENGTH.ConfigIncludeErrorwith message matching/null bytes?/ifor paths containing\0(e.g."./file\x00.json","./a\x00b.json"); keep existing test for//etc/passwd(path traversal).MAX_INCLUDE_PATH_LENGTH + 1and exactlyMAX_INCLUDE_PATH_LENGTH(which can still produce a resolved path over the limit) both throw with a message containing "maximum length".Documentation
docs/gateway/configuration-reference.md(Config includes section): Document that paths must not contain null bytes and must not exceed 4096 characters (before or after resolution); add "invalid path format (null bytes or excessive length)" to the Errors bullet.docs/gateway/configuration.md($include accordion): Add that paths must not contain null bytes and must not exceed 4096 characters; extend error handling to include invalid path format.Is this a feature?
No. This is security hardening and specification of existing behavior:
Testing
pnpm test src/config/includes.test.ts— all 28 tests pass.Checklist
resolvePath; clearConfigIncludeErrormessages.