fix: skills CRUD broken on Windows due to hardcoded path separator#492
Conversation
safeSkillDir() used a hardcoded `/` in the startsWith path traversal
check. On Windows, path.resolve() returns backslash paths, so the check
always failed — blocking getSkill, createSkill, updateSkill, deleteSkill.
Replace `${skillsDir}/` with `${skillsDir}${sep}` using path.sep from
node:path, which returns `\` on Windows and `/` on POSIX.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Greptile (@greptileai) review |
Greptile SummaryThis PR fixes a real Windows bug in Key changes:
Confidence Score: 4/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["HTTP Request\nGET /skills/:id"] --> B["Hono router extracts :id\n(URL-normalised)"]
B --> C["getSkill(id)"]
C --> D["safeSkillDir(id)"]
D --> E["path.resolve(skillsDir, id)"]
E --> F{{"resolved.startsWith\n(skillsDir + path.sep)?"}}
F -- "No (traversal / same dir)" --> G["throw 'Invalid skill id'\n→ 500 (unhandled in GET route)"]
F -- "Yes" --> H["fileExists(SKILL.md)?"]
H -- "No" --> I["return null → 404"]
H -- "Yes" --> J["read & parse SKILL.md\n→ 200"]
Prompt To Fix All With AIThis is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 17-20
Comment:
**Path traversal test doesn't actually exercise the security check**
The URL `'/../../../etc/passwd'` is normalized by the URL parser before Hono routes it. Per the URL standard, `new URL('/../../../etc/passwd', 'http://localhost')` resolves to `http://localhost/etc/passwd`, so `:id` becomes `"etc/passwd"` — not `"../../../etc/passwd"`.
As a result, `safeSkillDir("etc/passwd")` resolves to `${skillsDir}/etc/passwd`, which **passes** the traversal check (it's inside the skills dir). The test returns 404 only because no `SKILL.md` exists at that path, not because the security guard fired.
To actually exercise `safeSkillDir`'s protection, test the service function directly or use percent-encoded dots that survive URL normalization:
```ts
import { safeSkillDir } from '../../src/skills/service' // if exported, or test via internal module
// Or use percent-encoded traversal that bypasses URL normalization:
const res = await app.request('/..%2F..%2F..%2Fetc%2Fpasswd')
assert.notStrictEqual(res.status, 200)
```
Alternatively, if `safeSkillDir` is kept private, add a unit test that calls it directly with `'../../../etc/passwd'` and asserts it throws.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 23-26
Comment:
**Tautological test adds no value**
This test asserts that `path.sep` is either `'/'` or `'\\'`. This will never fail in any valid Node.js/Bun runtime — it's testing a Node.js built-in constant rather than any application logic.
Consider replacing it with a test that actually validates `safeSkillDir` rejects a crafted traversal ID directly:
```ts
import { safeSkillDir } from '../../src/skills/service'
describe('safeSkillDir', () => {
it('throws on path traversal attempts', () => {
assert.throws(() => safeSkillDir('../../../etc/passwd'), /Invalid skill id/)
})
it('accepts a valid slug-like id', () => {
assert.doesNotThrow(() => safeSkillDir('my-skill'))
})
})
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 18-19
Comment:
**Weak assertion — passes even for 5xx errors**
`assert.notStrictEqual(res.status, 200)` passes for any non-200 response, including `500` (which would indicate an unhandled exception rather than a clean security rejection). Consider asserting a specific 4xx status to confirm the server handles the case gracefully:
```suggestion
assert.ok(res.status >= 400 && res.status < 500, `expected 4xx, got ${res.status}`)
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 8295213 |
| it('GET /:id rejects path traversal attempts', async () => { | ||
| const res = await app.request('/../../../etc/passwd') | ||
| assert.notStrictEqual(res.status, 200) | ||
| }) |
There was a problem hiding this comment.
Path traversal test doesn't actually exercise the security check
The URL '/../../../etc/passwd' is normalized by the URL parser before Hono routes it. Per the URL standard, new URL('/../../../etc/passwd', 'http://localhost') resolves to http://localhost/etc/passwd, so :id becomes "etc/passwd" — not "../../../etc/passwd".
As a result, safeSkillDir("etc/passwd") resolves to ${skillsDir}/etc/passwd, which passes the traversal check (it's inside the skills dir). The test returns 404 only because no SKILL.md exists at that path, not because the security guard fired.
To actually exercise safeSkillDir's protection, test the service function directly or use percent-encoded dots that survive URL normalization:
import { safeSkillDir } from '../../src/skills/service' // if exported, or test via internal module
// Or use percent-encoded traversal that bypasses URL normalization:
const res = await app.request('/..%2F..%2F..%2Fetc%2Fpasswd')
assert.notStrictEqual(res.status, 200)Alternatively, if safeSkillDir is kept private, add a unit test that calls it directly with '../../../etc/passwd' and asserts it throws.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/server/tests/skills/service.test.ts
Line: 17-20
Comment:
**Path traversal test doesn't actually exercise the security check**
The URL `'/../../../etc/passwd'` is normalized by the URL parser before Hono routes it. Per the URL standard, `new URL('/../../../etc/passwd', 'http://localhost')` resolves to `http://localhost/etc/passwd`, so `:id` becomes `"etc/passwd"` — not `"../../../etc/passwd"`.
As a result, `safeSkillDir("etc/passwd")` resolves to `${skillsDir}/etc/passwd`, which **passes** the traversal check (it's inside the skills dir). The test returns 404 only because no `SKILL.md` exists at that path, not because the security guard fired.
To actually exercise `safeSkillDir`'s protection, test the service function directly or use percent-encoded dots that survive URL normalization:
```ts
import { safeSkillDir } from '../../src/skills/service' // if exported, or test via internal module
// Or use percent-encoded traversal that bypasses URL normalization:
const res = await app.request('/..%2F..%2F..%2Fetc%2Fpasswd')
assert.notStrictEqual(res.status, 200)
```
Alternatively, if `safeSkillDir` is kept private, add a unit test that calls it directly with `'../../../etc/passwd'` and asserts it throws.
How can I resolve this? If you propose a fix, please make it concise.
Summary
safeSkillDir()path traversal check that used a hardcoded/instead ofpath.seppath.resolve()returns backslash paths (C:\Users\...), so thestartsWithcheck always failedDesign
Replace
${skillsDir}/with${skillsDir}${sep}usingpath.sepfromnode:path. This returns\on Windows and/on POSIX, matching whatpath.resolve()produces. The path traversal security guarantee is preserved.Test plan
🤖 Generated with Claude Code