mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
2 Commits
main
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
674a8dd72c | ||
|
|
8c4a5fa5aa |
@@ -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 取本地 basename;path 由平台生成 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)
|
||||
}
|
||||
|
||||
@@ -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 }{
|
||||
|
||||
Reference in New Issue
Block a user