Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/server/src/skills/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { join, resolve, sep } from 'node:path'
import matter from 'gray-matter'
import { getSkillsDir } from '../lib/browseros-dir'
import { logger } from '../lib/logger'
Expand All @@ -23,7 +23,7 @@ export function slugify(name: string): string {
function safeSkillDir(id: string): string {
const skillsDir = getSkillsDir()
const resolved = resolve(skillsDir, id)
if (!resolved.startsWith(`${skillsDir}/`)) {
if (!resolved.startsWith(`${skillsDir}${sep}`)) {
throw new Error('Invalid skill id')
}
return resolved
Expand Down
27 changes: 27 additions & 0 deletions apps/server/tests/skills/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import { sep } from 'node:path'

import { createSkillsRoutes } from '../../src/api/routes/skills'

describe('skills routes', () => {
const app = createSkillsRoutes()

it('GET /:id returns 404 for non-existent skill (not 500 from path check)', async () => {
const res = await app.request('/valid-skill-id')
assert.strictEqual(res.status, 404)
const body = await res.json()
assert.strictEqual(body.error, 'Skill not found')
})

it('GET /:id rejects path traversal attempts', async () => {
const res = await app.request('/../../../etc/passwd')
assert.notStrictEqual(res.status, 200)
})
Comment on lines +17 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

})

describe('safeSkillDir uses platform separator', () => {
it(`path.sep is "${sep}" on this platform`, () => {
assert.ok(sep === '/' || sep === '\\')
})
})
Loading