Compare commits

...

2 Commits

Author SHA1 Message Date
陈兴炀
674a8dd72c docs(apps): explain bounded risk of +file-upload path bypass
Expand the security NOTE: the accepted risk is bounded because the upload
destination is controlled, not attacker-chosen. The miaoda apps flow reads
the local file and uploads it to a presigned upload_url from miaoda's own
file_pre_upload endpoint (a miaoda-owned domain, into the caller's own app
storage under their own token). Widening --file only changes what can be
read locally, not where it can be sent, so it is not an exfiltration
primitive.
2026-07-21 11:48:06 +08:00
陈兴炀
8c4a5fa5aa feat(apps): support absolute paths for +file-upload --file
file-upload now reads the local --file via os directly instead of the
sandboxed rctx.FileIO(), so absolute paths — and paths outside the
current working directory — are accepted. Previously the shared
SafeInputPath jail rejected any non-relative / out-of-tree path.

Relative-path behavior is unchanged (os resolves the same path against
the same cwd); --file required, directory rejection, and the 100 MB cap
are all preserved. The two os calls carry //nolint:forbidigo with a
rationale, matching the existing precedent in apps_env_pull.go.

Adds a unit test covering an absolute, out-of-tree upload path.
2026-07-21 11:04:19 +08:00
2 changed files with 73 additions and 3 deletions

View File

@@ -10,11 +10,11 @@ import (
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,6 +27,21 @@ const fileUploadMaxBytes = 100 * 1024 * 1024
// 2. 客户端 PUT 文件字节到 presigned upload_url取响应 ETag
// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据
// file_name 取本地 basenamepath 由平台生成 16 位 ID不可指定。仅收 --file。
//
// NOTE: unlike other --file consumers, file-upload reads the local path via os
// directly instead of the sandboxed rctx.FileIO(). This deliberately allows
// absolute paths (and paths outside the working directory), bypassing the
// SafeInputPath jail — the source file is only read locally and streamed to the
// app's storage, so an operator may upload from anywhere on the machine.
//
// Why the accepted risk is bounded: the upload destination is CONTROLLED, not
// attacker-chosen. The miaoda apps flow reads the local file and then uploads it
// to the remote server — the bytes are PUT to a presigned upload_url returned by
// miaoda's own file_pre_upload endpoint (step 1), i.e. a miaoda-owned domain,
// into the caller's own app storage under their own user token. Widening --file
// only changes what can be READ locally (already readable by the token holder),
// not where it can be SENT, so this is not an arbitrary-exfiltration primitive
// and the security risk is acceptable.
var AppsFileUpload = common.Shortcut{
Service: appsService,
Command: "+file-upload",
@@ -51,7 +66,11 @@ var AppsFileUpload = common.Shortcut{
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
// --file is stat'd via os directly rather than rctx.FileIO(): file-upload
// intentionally accepts any local path — including absolute and paths
// outside the working directory — which the shared FileIO sandbox rejects.
//nolint:forbidigo // shortcuts cannot import internal/vfs; file-upload deliberately reads an arbitrary local path to upload (see command doc).
st, err := os.Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
@@ -76,7 +95,10 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
// Read via os directly (not rctx.FileIO()) so an absolute / out-of-tree
// path is accepted; see the Stat call in Validate for the rationale.
//nolint:forbidigo // shortcuts cannot import internal/vfs; file-upload deliberately reads an arbitrary local path to upload (see command doc).
content, err := os.ReadFile(localPath)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}

View File

@@ -149,6 +149,54 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath 验证 file-upload 接受绝对路径(且位于工作目录之外):
// 此处 NOT chdir--file 传 t.TempDir() 下的绝对路径,正好落在 cwd 之外,
// 旧的 FileIO 沙箱会拒must be a relative path within the current directory
// 改用 os 直读后应成功直传。
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// 绝对路径,且不 chdir 进去 → 相对 cwd 在目录树之外。
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{