Skip to content

Commit 4f34945

Browse files
committed
🐛 Restore static resources in symlinked workspaces #19178
1 parent 8767434 commit 4f34945

3 files changed

Lines changed: 140 additions & 10 deletions

File tree

kernel/server/serve_static_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,75 @@ func TestStaticFileNestedSymlinkEscape(t *testing.T) {
166166
}
167167
}
168168

169+
// TestStaticFileSymlinkWorkspace 验证工作空间父目录符号链接不影响资源访问及敏感路径拦截。
170+
func TestStaticFileSymlinkWorkspace(t *testing.T) {
171+
gin.SetMode(gin.TestMode)
172+
realHome, err := filepath.EvalSymlinks(t.TempDir())
173+
if err != nil {
174+
t.Fatal(err)
175+
}
176+
linkedHome := filepath.Join(t.TempDir(), "home")
177+
if err = os.Symlink(realHome, linkedHome); err != nil {
178+
t.Skipf("create directory symlink failed: %s", err)
179+
}
180+
originalWorkspace := util.WorkspaceDir
181+
util.WorkspaceDir = filepath.Join(linkedHome, ".var", "app", "org.b3log.siyuan", "SiYuan")
182+
t.Cleanup(func() { util.WorkspaceDir = originalWorkspace })
183+
for _, rel := range []string{
184+
"data/emojis/fontawesome-free-solid/robot.svg",
185+
"data/widgets/listChildDocs/index.html",
186+
"data/widgets/listChildDocs/app.js",
187+
"data/widgets/listChildDocs/credentials.json",
188+
"conf/conf.json",
189+
"temp/private.txt",
190+
} {
191+
p := filepath.Join(util.WorkspaceDir, filepath.FromSlash(rel))
192+
if err = os.MkdirAll(filepath.Dir(p), 0755); err != nil {
193+
t.Fatal(err)
194+
}
195+
if err = os.WriteFile(p, []byte("content"), 0644); err != nil {
196+
t.Fatal(err)
197+
}
198+
}
199+
widgets := filepath.Join(util.WorkspaceDir, "data", "widgets")
200+
for _, dir := range []string{"conf", "temp"} {
201+
if err = os.Symlink(filepath.Join(util.WorkspaceDir, dir), filepath.Join(widgets, dir)); err != nil {
202+
t.Fatal(err)
203+
}
204+
}
205+
if err = os.Symlink(filepath.Join(util.WorkspaceDir, "conf"), filepath.Join(widgets, "listChildDocs", "escape")); err != nil {
206+
t.Fatal(err)
207+
}
208+
engine := gin.New()
209+
registerStaticFileHandlers(engine.Group("/emojis"), filepath.Join(util.WorkspaceDir, "data", "emojis"), false, nil)
210+
registerStaticFileHandlers(engine.Group("/widgets"), widgets, true, nil)
211+
for _, test := range []struct {
212+
path string
213+
status int
214+
}{
215+
{"/emojis/fontawesome-free-solid/robot.svg", http.StatusOK},
216+
{"/widgets/listChildDocs/", http.StatusOK},
217+
{"/widgets/listChildDocs/app.js", http.StatusOK},
218+
{"/widgets/listChildDocs/credentials.json", http.StatusForbidden},
219+
{"/widgets/conf/conf.json", http.StatusForbidden},
220+
{"/widgets/temp/private.txt", http.StatusForbidden},
221+
{"/widgets/listChildDocs/escape/conf.json", http.StatusForbidden},
222+
} {
223+
for _, method := range []string{http.MethodGet, http.MethodHead} {
224+
t.Run(method+" "+test.path, func(t *testing.T) {
225+
recorder := httptest.NewRecorder()
226+
engine.ServeHTTP(recorder, httptest.NewRequest(method, test.path, nil))
227+
if recorder.Code != test.status {
228+
t.Fatalf("status = %d, want %d", recorder.Code, test.status)
229+
}
230+
if method == http.MethodGet && test.status == http.StatusOK && recorder.Body.String() != "content" {
231+
t.Fatalf("unexpected response body: %q", recorder.Body.String())
232+
}
233+
})
234+
}
235+
}
236+
}
237+
169238
func TestWidgetResponseCacheControl(t *testing.T) {
170239
gin.SetMode(gin.TestMode)
171240
originalDataDir, originalConf := util.DataDir, model.Conf

kernel/util/path.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -467,17 +467,24 @@ func IsSensitivePath(p string) bool {
467467
return false
468468
}
469469

470-
// isSensitivePath 执行实际的敏感性黑名单匹配,不解析符号链接
470+
// isSensitivePath 执行敏感性黑名单匹配,必要时解析工作空间路径,但不解析目标路径
471471
func isSensitivePath(p string) bool {
472472
toCheckPathLower := filepath.Clean(strings.ToLower(p))
473473
toCheckNameLower := filepath.Base(toCheckPathLower)
474+
workspaceDir := WorkspaceDir
475+
inWorkspace := gulu.File.IsSubPath(workspaceDir, p)
476+
if !inWorkspace && workspaceDir != "" {
477+
// 静态资源使用解析后的真实路径,工作空间也需采用相同形式判断归属及 conf、temp 目录。
478+
// 仅解析工作空间根目录,不能将指向外部敏感文件的资源符号链接视为工作空间内文件。
479+
if resolved, err := filepath.EvalSymlinks(workspaceDir); err == nil && gulu.File.IsSubPath(resolved, p) {
480+
workspaceDir = resolved
481+
inWorkspace = true
482+
}
483+
}
474484

475-
// 系统目录前缀检查仅对工作空间外的路径执行。
476-
// 调用方传入的工作空间内路径(如 assets、export)都已用 IsSubPath(WorkspaceDir) 校验过,
477-
// 工作空间不可能位于 /etc、/var/log 等系统敏感目录;而 iOS 等沙箱平台的合法数据路径恰好以
478-
// /var 开头(/var/mobile/Containers/Data/Application/...),对工作空间内路径执行系统目录前缀
479-
// 检查会把 iOS 上正常的 assets/export 文件误判为敏感路径,导致伺服返回 403。
480-
if !gulu.File.IsSubPath(WorkspaceDir, p) {
485+
// 系统目录前缀检查仅对工作空间外的路径执行,工作空间内仍需检查配置、临时文件和凭据。
486+
// iOS 沙箱及 Linux /var/home 下的合法工作空间可能位于 /var,需按工作空间边界判断。
487+
if !inWorkspace {
481488
// 敏感目录前缀(UNIX 风格)
482489
prefixes := []string{
483490
"/.",
@@ -527,14 +534,14 @@ func isSensitivePath(p string) bool {
527534
}
528535

529536
// 工作空间/conf 目录(小写比较)
530-
workspaceConfPrefix := strings.ToLower(filepath.Join(WorkspaceDir, "conf"))
537+
workspaceConfPrefix := strings.ToLower(filepath.Join(workspaceDir, "conf"))
531538
if strings.HasPrefix(toCheckPathLower, workspaceConfPrefix) {
532539
return true
533540
}
534541

535542
// 只允许导出工作空间/temp/export 目录,不允许导出工作空间/temp 目录(小写比较)
536-
workspaceTempExportPrefix := strings.ToLower(filepath.Join(WorkspaceDir, "temp", "export"))
537-
workspaceTempPrefix := strings.ToLower(filepath.Join(WorkspaceDir, "temp"))
543+
workspaceTempExportPrefix := strings.ToLower(filepath.Join(workspaceDir, "temp", "export"))
544+
workspaceTempPrefix := strings.ToLower(filepath.Join(workspaceDir, "temp"))
538545
if strings.HasPrefix(toCheckPathLower, workspaceTempPrefix) && !strings.HasPrefix(toCheckPathLower, workspaceTempExportPrefix) {
539546
return true
540547
}

kernel/util/path_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,57 @@ func TestIsSensitivePathWorkspaceFilesNotBlocked(t *testing.T) {
169169
}
170170
}
171171
}
172+
173+
// TestIsSensitivePathSymlinkWorkspace 验证工作空间父目录为符号链接时的真实路径判定。
174+
func TestIsSensitivePathSymlinkWorkspace(t *testing.T) {
175+
realHome, err := filepath.EvalSymlinks(t.TempDir())
176+
if err != nil {
177+
t.Fatal(err)
178+
}
179+
linkedHome := filepath.Join(t.TempDir(), "home")
180+
if err = os.Symlink(realHome, linkedHome); err != nil {
181+
t.Skipf("create directory symlink failed: %s", err)
182+
}
183+
relWorkspace := filepath.Join(".var", "app", "org.b3log.siyuan", "SiYuan")
184+
realWorkspace := filepath.Join(realHome, relWorkspace)
185+
if err = os.MkdirAll(realWorkspace, 0755); err != nil {
186+
t.Fatal(err)
187+
}
188+
originalHome, originalWorkspace := HomeDir, WorkspaceDir
189+
HomeDir, WorkspaceDir = realHome, filepath.Join(linkedHome, relWorkspace)
190+
t.Cleanup(func() { HomeDir, WorkspaceDir = originalHome, originalWorkspace })
191+
192+
for _, test := range []struct {
193+
rel string
194+
sensitive bool
195+
}{
196+
{"data/emojis/fontawesome-free-solid/robot.svg", false},
197+
{"data/widgets/listChildDocs/index.html", false},
198+
{"data/assets/image.png", false},
199+
{"temp/export/document.html", false},
200+
{"conf/conf.json", true},
201+
{"temp/private.txt", true},
202+
{"data/widgets/example/credentials.json", true},
203+
{"data/widgets/example/id_rsa", true},
204+
} {
205+
t.Run(test.rel, func(t *testing.T) {
206+
for _, root := range []string{WorkspaceDir, realWorkspace} {
207+
p := filepath.Join(root, filepath.FromSlash(test.rel))
208+
if got := IsSensitivePath(p); got != test.sensitive {
209+
t.Errorf("IsSensitivePath(%q) = %v, want %v", p, got, test.sensitive)
210+
}
211+
}
212+
})
213+
}
214+
215+
// 工作空间外的凭据不能因工作空间解析成功而获得放行。
216+
if p := filepath.Join(realHome, ".ssh", "id_rsa"); !IsSensitivePath(p) {
217+
t.Errorf("external credential should be sensitive: %s", p)
218+
}
219+
if filepath.Separator == '/' {
220+
// Linux 临时目录同样命中系统目录黑名单,可覆盖 /var/home 场景且不写入系统家目录。
221+
if !isSensitivePath(filepath.Join(realWorkspace+"-outside", "public.txt")) {
222+
t.Fatal("workspace prefix sibling should remain sensitive")
223+
}
224+
}
225+
}

0 commit comments

Comments
 (0)