path-traversal.test.ts 6.6 KB

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