feat(workspace): add text editing and image previews
This commit is contained in:
+178
-1
@@ -3,8 +3,10 @@ package api
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -15,6 +17,8 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -117,7 +121,7 @@ func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
|
||||
}
|
||||
|
||||
func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/ws/download" {
|
||||
if r.URL.Path != "/api/ws/download" && r.URL.Path != "/api/ws/image" {
|
||||
var flush func()
|
||||
w, flush = deferJSONResponse(w)
|
||||
defer flush()
|
||||
@@ -190,11 +194,184 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
||||
a.wsUpload(w, r, root, u)
|
||||
case "download":
|
||||
a.wsDownload(w, r, root)
|
||||
case "image":
|
||||
a.wsImage(w, r, root)
|
||||
case "text":
|
||||
a.wsText(w, r, root, u)
|
||||
default:
|
||||
fail(w, 404, "not found")
|
||||
}
|
||||
}
|
||||
|
||||
const maxTextBytes = 1 << 20
|
||||
|
||||
func textStatus(content string) int {
|
||||
if len(content) > maxTextBytes {
|
||||
return 413
|
||||
}
|
||||
if !utf8.ValidString(content) {
|
||||
return 415
|
||||
}
|
||||
for _, c := range content {
|
||||
if unicode.IsControl(c) && c != 9 && c != 10 && c != 13 {
|
||||
return 415
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func textVersion(content string, st os.FileInfo) string {
|
||||
s := st.Sys().(*syscall.Stat_t)
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%d:%d:%d:%d:%s", s.Dev, s.Ino, st.ModTime().UnixNano(), s.Mode, s.Uid, s.Gid, content))))
|
||||
}
|
||||
|
||||
func readText(parent *os.File, name string) (string, string, os.FileInfo, int) {
|
||||
f, err := openDownload(parent, name)
|
||||
if err != nil {
|
||||
return "", "", nil, 404
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
return "", "", nil, 415
|
||||
}
|
||||
if st.Size() > maxTextBytes {
|
||||
return "", "", nil, 413
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(f, maxTextBytes+1))
|
||||
if err != nil {
|
||||
return "", "", nil, 500
|
||||
}
|
||||
if code := textStatus(string(b)); code != 0 {
|
||||
return "", "", nil, code
|
||||
}
|
||||
after, err := f.Stat()
|
||||
if err != nil || st.Size() != after.Size() || !st.ModTime().Equal(after.ModTime()) || st.Sys().(*syscall.Stat_t).Ctim != after.Sys().(*syscall.Stat_t).Ctim {
|
||||
return "", "", nil, 409
|
||||
}
|
||||
return string(b), textVersion(string(b), after), after, 0
|
||||
}
|
||||
|
||||
func (a *API) wsText(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPut {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
p := r.URL.Query().Get("path")
|
||||
if p == "" || path.IsAbs(p) || p == "." || p == ".." || strings.HasPrefix(p, "../") || path.Clean(p) != p || strings.ContainsRune(p, 0) {
|
||||
fail(w, 400, "invalid path")
|
||||
return
|
||||
}
|
||||
var x struct {
|
||||
Content *string `json:"content"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 6*maxTextBytes+4096))
|
||||
if err != nil {
|
||||
fail(w, 413, "text request too large")
|
||||
return
|
||||
}
|
||||
if !utf8.Valid(b) {
|
||||
fail(w, 415, "invalid UTF-8")
|
||||
return
|
||||
}
|
||||
if json.Unmarshal(b, &x) != nil || x.Content == nil || x.Version == "" {
|
||||
fail(w, 400, "content and version required")
|
||||
return
|
||||
}
|
||||
if code := textStatus(*x.Content); code != 0 {
|
||||
fail(w, code, "unsupported text or text too large")
|
||||
return
|
||||
}
|
||||
}
|
||||
user := a.userLocks.Acquire(u.ID)
|
||||
defer user.Release()
|
||||
anchor, err := root.Open(".")
|
||||
if err != nil {
|
||||
fail(w, 500, "storage unavailable")
|
||||
return
|
||||
}
|
||||
defer anchor.Close()
|
||||
fd, err := unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{
|
||||
Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC,
|
||||
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
|
||||
})
|
||||
if err != nil {
|
||||
code := 404
|
||||
if r.Method == http.MethodPut {
|
||||
code = 409
|
||||
}
|
||||
fail(w, code, "parent unavailable")
|
||||
return
|
||||
}
|
||||
parent := os.NewFile(uintptr(fd), path.Dir(p))
|
||||
defer parent.Close()
|
||||
content, version, st, code := readText(parent, path.Base(p))
|
||||
if code != 0 {
|
||||
if r.Method == http.MethodPut && code == 404 {
|
||||
code = 409
|
||||
}
|
||||
fail(w, code, "text file unavailable")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, map[string]string{"content": content, "version": version})
|
||||
return
|
||||
}
|
||||
if version != x.Version {
|
||||
fail(w, 409, "file changed; reload before saving")
|
||||
return
|
||||
}
|
||||
if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", u.ID).Scan(&u.Quota); err != nil {
|
||||
fail(w, 413, "quota unavailable")
|
||||
return
|
||||
}
|
||||
used, err := a.userUsage(u)
|
||||
if err != nil || int64(len(*x.Content)) > u.Quota-used {
|
||||
fail(w, 413, "quota exceeded or usage unavailable")
|
||||
return
|
||||
}
|
||||
tmp := ".text-" + store.ID()
|
||||
tfd, err := unix.Openat2(fd, tmp, &unix.OpenHow{Flags: unix.O_WRONLY | unix.O_CREAT | unix.O_EXCL | unix.O_CLOEXEC, Mode: 0600, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS})
|
||||
if err != nil {
|
||||
fail(w, 500, "cannot prepare text save")
|
||||
return
|
||||
}
|
||||
f := os.NewFile(uintptr(tfd), tmp)
|
||||
defer f.Close()
|
||||
defer unix.Unlinkat(fd, tmp, 0)
|
||||
_, err = io.WriteString(f, *x.Content)
|
||||
s := st.Sys().(*syscall.Stat_t)
|
||||
if err == nil {
|
||||
err = f.Chown(int(s.Uid), int(s.Gid))
|
||||
}
|
||||
if err == nil {
|
||||
err = unix.Fchmod(tfd, s.Mode&07777)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
next, statErr := f.Stat()
|
||||
if err != nil || statErr != nil {
|
||||
fail(w, 500, "cannot prepare text save")
|
||||
return
|
||||
}
|
||||
nextVersion := textVersion(*x.Content, next)
|
||||
_, latest, _, code := readText(parent, path.Base(p))
|
||||
if code != 0 || latest != version {
|
||||
fail(w, 409, "file changed; reload before saving")
|
||||
return
|
||||
}
|
||||
// External writers do not take the user lock; this is not a filesystem CAS.
|
||||
if err := unix.Renameat(fd, tmp, fd, path.Base(p)); err != nil {
|
||||
fail(w, 500, "text save failed")
|
||||
return
|
||||
}
|
||||
a.s.AddAudit(u.ID, "workspace.text", p, "")
|
||||
writeJSON(w, map[string]string{"version": nextVersion})
|
||||
}
|
||||
|
||||
func entry(root *os.Root, rel string) (os.FileInfo, error) {
|
||||
if err := rootSafe(root, rel, false); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
@@ -20,6 +21,188 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkspaceText(t *testing.T) {
|
||||
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||
for _, i := range []store.Instance{{ID: "one", UID: "owner"}, {ID: "foreign", UID: "other"}} {
|
||||
if err := a.s.AddInstance(i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
dir := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(dir, "file")
|
||||
putFile := func(content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(file, []byte(content), 0751); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
putFile("original")
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 32<<20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := func(method, query, body, session string, want int) map[string]string {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, runtimeRequest(method, "/api/ws/text?"+query, body, session))
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s %s: got %d want %d: %s", method, query, w.Code, want, w.Body.String())
|
||||
}
|
||||
out := map[string]string{}
|
||||
if want == 200 {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
q := "instance=one&path=file"
|
||||
body := func(content, version string) string {
|
||||
b, _ := json.Marshal(map[string]string{"content": content, "version": version})
|
||||
return string(b)
|
||||
}
|
||||
request("GET", q, "", "", 401)
|
||||
request("GET", "instance=foreign&path=file", "", sid, 404)
|
||||
request("GET", "path=file", "", sid, 410)
|
||||
for _, p := range []string{"../file", "..", "/etc/passwd", ".", "", "\x00", "./file", "a/../file", "a/./file", "a//file", "file/"} {
|
||||
for _, method := range []string{"GET", "PUT"} {
|
||||
request(method, "instance=one&path="+url.QueryEscape(p), "", sid, 400)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct{ exact, counterpart string }{
|
||||
{"config.yaml ", "config.yaml"},
|
||||
{" config.yaml", "config.yaml"},
|
||||
{`a\b`, "a/b"},
|
||||
} {
|
||||
for name, content := range map[string]string{tc.exact: "exact", tc.counterpart: "counterpart"} {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
exactQuery := "instance=one&path=" + url.QueryEscape(tc.exact)
|
||||
got := request("GET", exactQuery, "", sid, 200)
|
||||
if got["content"] != "exact" {
|
||||
t.Fatalf("GET %q returned %q", tc.exact, got["content"])
|
||||
}
|
||||
request("PUT", exactQuery, body("updated", got["version"]), sid, 200)
|
||||
if got := request("GET", exactQuery, "", sid, 200); got["content"] != "updated" {
|
||||
t.Fatalf("GET %q after PUT returned %q", tc.exact, got["content"])
|
||||
}
|
||||
for name, want := range map[string]string{tc.exact: "updated", tc.counterpart: "counterpart"} {
|
||||
got, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("file %q: got %q, want %q: %v", name, got, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
request("POST", q, "", sid, 405)
|
||||
v := request("GET", q, "", sid, 200)["version"]
|
||||
if v == "" {
|
||||
t.Fatal("missing version")
|
||||
}
|
||||
request("PUT", q, "{}", sid, 400)
|
||||
request("PUT", q, body("next", "stale"), sid, 409)
|
||||
request("PUT", q, body("next", ""), sid, 400)
|
||||
for _, content := range []string{"nul\x00", "del\x7f", "control\x01", "invalid\xff"} {
|
||||
putFile(content)
|
||||
request("GET", q, "", sid, 415)
|
||||
}
|
||||
putFile(strings.Repeat("x", maxTextBytes+1))
|
||||
request("GET", q, "", sid, 413)
|
||||
putFile(strings.Repeat("x", maxTextBytes))
|
||||
request("GET", q, "", sid, 200)
|
||||
putFile("original")
|
||||
v = request("GET", q, "", sid, 200)["version"]
|
||||
request("PUT", q, body(strings.Repeat("x", maxTextBytes+1), v), sid, 413)
|
||||
request("PUT", q, body("bad\x00", v), sid, 415)
|
||||
request("PUT", q, "{\"content\":\"\xff\",\"version\":\"v\"}", sid, 415)
|
||||
request("PUT", q, strings.Repeat(" ", 6*maxTextBytes+4097), sid, 413)
|
||||
if err := os.Symlink(file, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(dir, filepath.Join(dir, "parent")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"link", "parent/file"} {
|
||||
request("GET", "instance=one&path="+p, "", sid, 404)
|
||||
request("PUT", "instance=one&path="+p, body("next", v), sid, 409)
|
||||
}
|
||||
if err := os.Link(file, filepath.Join(dir, "hard")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request("GET", q, "", sid, 404)
|
||||
request("PUT", q, body("next", v), sid, 409)
|
||||
if err := os.Remove(filepath.Join(dir, "hard")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET quota=8 WHERE id='owner'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request("PUT", q, body("next", v), sid, 413)
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 32<<20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := "你好\tline\r\n"
|
||||
saved := request("PUT", q, body(next, v), sid, 200)["version"]
|
||||
got := request("GET", q, "", sid, 200)
|
||||
if got["content"] != next || got["version"] != saved || saved == v {
|
||||
t.Fatal(got, saved)
|
||||
}
|
||||
after, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bs, as := before.Sys().(*syscall.Stat_t), after.Sys().(*syscall.Stat_t)
|
||||
if bs.Mode != as.Mode || bs.Uid != as.Uid || bs.Gid != as.Gid {
|
||||
t.Fatal("metadata changed")
|
||||
}
|
||||
request("PUT", q, body("stale save", v), sid, 409)
|
||||
results := make(chan int, 2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, runtimeRequest("PUT", "/api/ws/text?"+q, body("concurrent", saved), sid))
|
||||
results <- w.Code
|
||||
}()
|
||||
}
|
||||
first, second := <-results, <-results
|
||||
if !(first == 200 && second == 409 || first == 409 && second == 200) {
|
||||
t.Fatalf("concurrent saves: %d %d", first, second)
|
||||
}
|
||||
saved = request("GET", q, "", sid, 200)["version"]
|
||||
putFile("external")
|
||||
request("PUT", q, body("stale save", saved), sid, 409)
|
||||
if err := os.Remove(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request("PUT", q, body("recreate", saved), sid, 409)
|
||||
request("GET", q, "", sid, 404)
|
||||
putFile("restored")
|
||||
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='one'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request("PUT", q, body("blocked", saved), sid, 409)
|
||||
request("GET", q, "", sid, 200)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".text-") {
|
||||
t.Fatal("temporary file leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadExtensionPolicy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, policy string
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const maxImageBytes = 16 << 20
|
||||
|
||||
func (a *API) wsImage(w http.ResponseWriter, r *http.Request, root *os.Root) {
|
||||
if r.Method != http.MethodGet {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
p := r.URL.Query().Get("path")
|
||||
if p == "" || path.IsAbs(p) || p == "." || p == ".." || strings.HasPrefix(p, "../") || path.Clean(p) != p || strings.ContainsRune(p, 0) {
|
||||
fail(w, 400, "invalid path")
|
||||
return
|
||||
}
|
||||
anchor, err := root.Open(".")
|
||||
if err != nil {
|
||||
fail(w, 500, "storage unavailable")
|
||||
return
|
||||
}
|
||||
defer anchor.Close()
|
||||
f, err := openDownload(anchor, p)
|
||||
if err != nil {
|
||||
fail(w, 404, "image unavailable")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
fail(w, 415, "unsupported image")
|
||||
return
|
||||
}
|
||||
if st.Size() > maxImageBytes {
|
||||
fail(w, 413, "image too large")
|
||||
return
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(f, maxImageBytes+1))
|
||||
if err != nil {
|
||||
fail(w, 500, "image read failed")
|
||||
return
|
||||
}
|
||||
if len(b) > maxImageBytes {
|
||||
fail(w, 413, "image too large")
|
||||
return
|
||||
}
|
||||
after, err := f.Stat()
|
||||
if err != nil || int64(len(b)) != st.Size() || st.Size() != after.Size() || !st.ModTime().Equal(after.ModTime()) || st.Sys().(*syscall.Stat_t).Ctim != after.Sys().(*syscall.Stat_t).Ctim {
|
||||
fail(w, 409, "image changed; reload")
|
||||
return
|
||||
}
|
||||
mimeType := workspaceImageType(p, b)
|
||||
if mimeType == "" {
|
||||
fail(w, 415, "unsupported image")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func workspaceImageType(name string, b []byte) string {
|
||||
switch mimeType := http.DetectContentType(b); mimeType {
|
||||
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
||||
return mimeType
|
||||
}
|
||||
if !strings.EqualFold(path.Ext(name), ".svg") {
|
||||
return ""
|
||||
}
|
||||
d := xml.NewDecoder(bytes.NewReader(bytes.TrimPrefix(b, []byte{0xef, 0xbb, 0xbf})))
|
||||
depth, roots := 0, 0
|
||||
for {
|
||||
token, err := d.Token()
|
||||
if err == io.EOF {
|
||||
if roots == 1 && depth == 0 {
|
||||
return "image/svg+xml"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
roots++
|
||||
if roots != 1 || t.Name.Local != "svg" || t.Name.Space != "http://www.w3.org/2000/svg" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && len(bytes.TrimSpace(t)) != 0 {
|
||||
return ""
|
||||
}
|
||||
case xml.Directive:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
func TestWorkspaceImage(t *testing.T) {
|
||||
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||
for _, i := range []store.Instance{{ID: "one", UID: "owner"}, {ID: "foreign", UID: "other"}} {
|
||||
if err := a.s.AddInstance(i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
dir := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put := func(name string, b []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), b, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := func(method, query, session string, want int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, runtimeRequest(method, "/api/ws/image?"+query, "", session))
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s %s: got %d want %d: %s", method, query, w.Code, want, w.Body.String())
|
||||
}
|
||||
if len(a.workspaceReaders) != 0 {
|
||||
t.Fatal("workspace lease leaked")
|
||||
}
|
||||
return w
|
||||
}
|
||||
get := func(name string, want int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return request("GET", "instance=one&path="+url.QueryEscape(name), sid, want)
|
||||
}
|
||||
request("GET", "instance=one&path=file", "", 401)
|
||||
request("GET", "instance=foreign&path=file", sid, 404)
|
||||
request("GET", "instance=missing&path=file", sid, 404)
|
||||
request("GET", "path=file", sid, 410)
|
||||
for _, method := range []string{"HEAD", "POST", "PUT", "DELETE", "PATCH"} {
|
||||
request(method, "instance=one&path=file", sid, 405)
|
||||
}
|
||||
for _, p := range []string{"", ".", "..", "../file", "/etc/passwd", "a/../file", "./file", "a//file", "file/", "a/./file", "a\x00b"} {
|
||||
get(p, 400)
|
||||
}
|
||||
get("missing", 404)
|
||||
for _, tc := range []struct{ name, content, mime string }{
|
||||
{"png.txt", "\x89PNG\r\n\x1a\n", "image/png"},
|
||||
{"jpeg", "\xff\xd8\xff\xe0", "image/jpeg"},
|
||||
{"gif87", "GIF87a", "image/gif"},
|
||||
{"gif89", "GIF89a", "image/gif"},
|
||||
{"webp", "RIFF\x00\x00\x00\x00WEBPVP8 ", "image/webp"},
|
||||
{"active.svg", `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><style>rect{fill:red}</style><script>alert(1)</script><foreignObject><html xmlns="http://www.w3.org/1999/xhtml"/></foreignObject></svg>`, "image/svg+xml"},
|
||||
{"prefix.SVG", `<?xml version="1.0"?><!--ok--><s:svg xmlns:s="http://www.w3.org/2000/svg"/>`, "image/svg+xml"},
|
||||
{" space ", "GIF89a exact", "image/gif"},
|
||||
{`a\b`, "GIF89a backslash", "image/gif"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
put(tc.name, []byte(tc.content))
|
||||
w := get(tc.name, 200)
|
||||
if w.Body.String() != tc.content {
|
||||
t.Fatal("raw image bytes changed")
|
||||
}
|
||||
disposition, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
|
||||
if err != nil || disposition != "attachment" || params["filename"] != tc.name {
|
||||
t.Fatalf("invalid download filename: %s", w.Header().Get("Content-Disposition"))
|
||||
}
|
||||
for key, want := range map[string]string{"Content-Type": tc.mime, "Content-Length": strconv.Itoa(len(tc.content)), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'"} {
|
||||
if got := w.Header().Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, content := range []string{"", "plain text", "<html><svg/></html>", `<svg/>`, `<svg xmlns="wrong"/>`, `<svg xmlns="http://www.w3.org/2000/svg">`, `<svg xmlns="http://www.w3.org/2000/svg"/><html/>`, `<svg xmlns="http://www.w3.org/2000/svg"/>junk`, `<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg xmlns="http://www.w3.org/2000/svg">&x;</svg>`, `<svg xmlns="http://www.w3.org/2000/svg">&unknown;</svg>`} {
|
||||
put("bad.svg", []byte(content))
|
||||
get("bad.svg", 415)
|
||||
}
|
||||
put("wrong.png", []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`))
|
||||
get("wrong.png", 415)
|
||||
put("fake.png", []byte("<html>not an image</html>"))
|
||||
get("fake.png", 415)
|
||||
if err := os.Mkdir(filepath.Join(dir, "directory"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get("directory", 415)
|
||||
if err := os.Symlink("png.txt", filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get("link", 404)
|
||||
if err := os.Symlink(dir, filepath.Join(dir, "parent")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get("parent/png.txt", 404)
|
||||
if err := os.Link(filepath.Join(dir, "png.txt"), filepath.Join(dir, "hard")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get("hard", 404)
|
||||
get("png.txt", 404)
|
||||
if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get("fifo", 404)
|
||||
large := bytes.Repeat([]byte{0}, maxImageBytes)
|
||||
copy(large, "GIF89a")
|
||||
put("limit", large)
|
||||
get("limit", 200)
|
||||
put("oversize", append(large, 0))
|
||||
get("oversize", 413)
|
||||
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='one'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get("gif89", 200)
|
||||
}
|
||||
Reference in New Issue
Block a user