path-traversal.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { test, expect, describe } from "bun:test"
  2. import { Effect } from "effect"
  3. import path from "path"
  4. import fs from "fs/promises"
  5. import { Filesystem } from "../../src/util/filesystem"
  6. import { File } from "../../src/file"
  7. import { Instance } from "../../src/project/instance"
  8. import { provideInstance, tmpdir } from "../fixture/fixture"
  9. const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
  10. Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
  11. const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
  12. const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
  13. describe("Filesystem.contains", () => {
  14. test("allows paths within project", () => {
  15. expect(Filesystem.contains("/project", "/project/src")).toBe(true)
  16. expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
  17. expect(Filesystem.contains("/project", "/project")).toBe(true)
  18. })
  19. test("blocks ../ traversal", () => {
  20. expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
  21. expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
  22. expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
  23. })
  24. test("blocks absolute paths outside project", () => {
  25. expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
  26. expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
  27. expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
  28. })
  29. test("handles prefix collision edge cases", () => {
  30. expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
  31. expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
  32. })
  33. })
  34. /*
  35. * Integration tests for read() and list() path traversal protection.
  36. *
  37. * These tests verify the HTTP API code path is protected. The HTTP endpoints
  38. * in server.ts (GET /file/content, GET /file) call read()/list()
  39. * directly - they do NOT go through ReadTool or the agent permission layer.
  40. *
  41. * This is a SEPARATE code path from ReadTool, which has its own checks.
  42. */
  43. describe("File.read path traversal protection", () => {
  44. test("rejects ../ traversal attempting to read /etc/passwd", async () => {
  45. await using tmp = await tmpdir({
  46. init: async (dir) => {
  47. await Bun.write(path.join(dir, "allowed.txt"), "allowed content")
  48. },
  49. })
  50. await Instance.provide({
  51. directory: tmp.path,
  52. fn: async () => {
  53. await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
  54. },
  55. })
  56. })
  57. test("rejects deeply nested traversal", async () => {
  58. await using tmp = await tmpdir()
  59. await Instance.provide({
  60. directory: tmp.path,
  61. fn: async () => {
  62. await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
  63. "Access denied: path escapes project directory",
  64. )
  65. },
  66. })
  67. })
  68. test("allows valid paths within project", async () => {
  69. await using tmp = await tmpdir({
  70. init: async (dir) => {
  71. await Bun.write(path.join(dir, "valid.txt"), "valid content")
  72. },
  73. })
  74. await Instance.provide({
  75. directory: tmp.path,
  76. fn: async () => {
  77. const result = await read("valid.txt")
  78. expect(result.content).toBe("valid content")
  79. },
  80. })
  81. })
  82. })
  83. describe("File.list path traversal protection", () => {
  84. test("rejects ../ traversal attempting to list /etc", async () => {
  85. await using tmp = await tmpdir()
  86. await Instance.provide({
  87. directory: tmp.path,
  88. fn: async () => {
  89. await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
  90. },
  91. })
  92. })
  93. test("allows valid subdirectory listing", async () => {
  94. await using tmp = await tmpdir({
  95. init: async (dir) => {
  96. await Bun.write(path.join(dir, "subdir", "file.txt"), "content")
  97. },
  98. })
  99. await Instance.provide({
  100. directory: tmp.path,
  101. fn: async () => {
  102. const result = await list("subdir")
  103. expect(Array.isArray(result)).toBe(true)
  104. },
  105. })
  106. })
  107. })
  108. describe("Instance.containsPath", () => {
  109. test("returns true for path inside directory", async () => {
  110. await using tmp = await tmpdir({ git: true })
  111. await Instance.provide({
  112. directory: tmp.path,
  113. fn: () => {
  114. expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
  115. expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
  116. },
  117. })
  118. })
  119. test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
  120. await using tmp = await tmpdir({ git: true })
  121. const subdir = path.join(tmp.path, "packages", "lib")
  122. await fs.mkdir(subdir, { recursive: true })
  123. await Instance.provide({
  124. directory: subdir,
  125. fn: () => {
  126. // .opencode at worktree root, but we're running from packages/lib
  127. expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
  128. // sibling package should also be accessible
  129. expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
  130. // worktree root itself
  131. expect(Instance.containsPath(tmp.path)).toBe(true)
  132. },
  133. })
  134. })
  135. test("returns false for path outside both directory and worktree", async () => {
  136. await using tmp = await tmpdir({ git: true })
  137. await Instance.provide({
  138. directory: tmp.path,
  139. fn: () => {
  140. expect(Instance.containsPath("/etc/passwd")).toBe(false)
  141. expect(Instance.containsPath("/tmp/other-project")).toBe(false)
  142. },
  143. })
  144. })
  145. test("returns false for path with .. escaping worktree", async () => {
  146. await using tmp = await tmpdir({ git: true })
  147. await Instance.provide({
  148. directory: tmp.path,
  149. fn: () => {
  150. expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
  151. },
  152. })
  153. })
  154. test("handles directory === worktree (running from repo root)", async () => {
  155. await using tmp = await tmpdir({ git: true })
  156. await Instance.provide({
  157. directory: tmp.path,
  158. fn: () => {
  159. expect(Instance.directory).toBe(Instance.worktree)
  160. expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
  161. expect(Instance.containsPath("/etc/passwd")).toBe(false)
  162. },
  163. })
  164. })
  165. test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
  166. await using tmp = await tmpdir() // no git: true
  167. await Instance.provide({
  168. directory: tmp.path,
  169. fn: () => {
  170. // worktree is "/" for non-git projects, but containsPath should NOT allow all paths
  171. expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
  172. expect(Instance.containsPath("/etc/passwd")).toBe(false)
  173. expect(Instance.containsPath("/tmp/other")).toBe(false)
  174. },
  175. })
  176. })
  177. })