···685685 return686686 }687687688688- if strings.Contains(contentType, "text/plain") {688688+ // Safely serve content based on type689689+ if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {690690+ // Serve all textual content as text/plain for security689691 w.Header().Set("Content-Type", "text/plain; charset=utf-8")690692 w.Write(body)691693 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {694694+ // Serve images and videos with their original content type692695 w.Header().Set("Content-Type", contentType)693696 w.Write(body)694697 } else {698698+ // Block potentially dangerous content types695699 w.WriteHeader(http.StatusUnsupportedMediaType)696700 w.Write([]byte("unsupported content type"))697701 return698702 }703703+}704704+705705+// isTextualMimeType returns true if the MIME type represents textual content706706+// that should be served as text/plain707707+func isTextualMimeType(mimeType string) bool {708708+ textualTypes := []string{709709+ "application/json",710710+ "application/xml",711711+ "application/yaml",712712+ "application/x-yaml",713713+ "application/toml",714714+ "application/javascript",715715+ "application/ecmascript",716716+ "message/",717717+ }718718+719719+ for _, t := range textualTypes {720720+ if mimeType == t {721721+ return true722722+ }723723+ }724724+ return false699725}700726701727// modify the spindle configured for this repo
+40
knotserver/db/pubkeys.go
···11package db2233import (44+ "strconv"45 "time"5667 "tangled.sh/tangled.sh/core/api/tangled"···9998 }10099101100 return keys, nil101101+}102102+103103+func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) {104104+ var keys []PublicKey105105+106106+ offset := 0107107+ if cursor != "" {108108+ if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {109109+ offset = o110110+ }111111+ }112112+113113+ query := `select key, did, created from public_keys order by created desc limit ? offset ?`114114+ rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results115115+ if err != nil {116116+ return nil, "", err117117+ }118118+ defer rows.Close()119119+120120+ for rows.Next() {121121+ var publicKey PublicKey122122+ if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil {123123+ return nil, "", err124124+ }125125+ keys = append(keys, publicKey)126126+ }127127+128128+ if err := rows.Err(); err != nil {129129+ return nil, "", err130130+ }131131+132132+ // check if there are more results for pagination133133+ var nextCursor string134134+ if len(keys) > limit {135135+ keys = keys[:limit] // remove the extra item136136+ nextCursor = strconv.Itoa(offset + limit)137137+ }138138+139139+ return keys, nextCursor, nil102140}
+5
knotserver/ingester.go
···9898 l := log.FromContext(ctx)9999 l = l.With("handler", "processPull")100100 l = l.With("did", did)101101+102102+ if record.Target == nil {103103+ return fmt.Errorf("ignoring pull record: target repo is nil")104104+ }105105+101106 l = l.With("target_repo", record.Target.Repo)102107 l = l.With("target_branch", record.Target.Branch)103108
+58
knotserver/xrpc/list_keys.go
···11+package xrpc22+33+import (44+ "encoding/json"55+ "net/http"66+ "strconv"77+88+ "tangled.sh/tangled.sh/core/api/tangled"99+ xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"1010+)1111+1212+func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {1313+ cursor := r.URL.Query().Get("cursor")1414+1515+ limit := 100 // default1616+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {1717+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {1818+ limit = l1919+ }2020+ }2121+2222+ keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor)2323+ if err != nil {2424+ x.Logger.Error("failed to get public keys", "error", err)2525+ writeError(w, xrpcerr.NewXrpcError(2626+ xrpcerr.WithTag("InternalServerError"),2727+ xrpcerr.WithMessage("failed to retrieve public keys"),2828+ ), http.StatusInternalServerError)2929+ return3030+ }3131+3232+ publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys))3333+ for _, key := range keys {3434+ publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{3535+ Did: key.Did,3636+ Key: key.Key,3737+ CreatedAt: key.CreatedAt,3838+ })3939+ }4040+4141+ response := tangled.KnotListKeys_Output{4242+ Keys: publicKeys,4343+ }4444+4545+ if nextCursor != "" {4646+ response.Cursor = &nextCursor4747+ }4848+4949+ w.Header().Set("Content-Type", "application/json")5050+ if err := json.NewEncoder(w).Encode(response); err != nil {5151+ x.Logger.Error("failed to encode response", "error", err)5252+ writeError(w, xrpcerr.NewXrpcError(5353+ xrpcerr.WithTag("InternalServerError"),5454+ xrpcerr.WithMessage("failed to encode response"),5555+ ), http.StatusInternalServerError)5656+ return5757+ }5858+}
+80
knotserver/xrpc/repo_archive.go
···11+package xrpc22+33+import (44+ "compress/gzip"55+ "fmt"66+ "net/http"77+ "strings"88+99+ "github.com/go-git/go-git/v5/plumbing"1010+1111+ "tangled.sh/tangled.sh/core/knotserver/git"1212+ xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"1313+)1414+1515+func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {1616+ repo, repoPath, unescapedRef, err := x.parseStandardParams(r)1717+ if err != nil {1818+ writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)1919+ return2020+ }2121+2222+ format := r.URL.Query().Get("format")2323+ if format == "" {2424+ format = "tar.gz" // default2525+ }2626+2727+ prefix := r.URL.Query().Get("prefix")2828+2929+ if format != "tar.gz" {3030+ writeError(w, xrpcerr.NewXrpcError(3131+ xrpcerr.WithTag("InvalidRequest"),3232+ xrpcerr.WithMessage("only tar.gz format is supported"),3333+ ), http.StatusBadRequest)3434+ return3535+ }3636+3737+ gr, err := git.Open(repoPath, unescapedRef)3838+ if err != nil {3939+ writeError(w, xrpcerr.NewXrpcError(4040+ xrpcerr.WithTag("RefNotFound"),4141+ xrpcerr.WithMessage("repository or ref not found"),4242+ ), http.StatusNotFound)4343+ return4444+ }4545+4646+ repoParts := strings.Split(repo, "/")4747+ repoName := repoParts[len(repoParts)-1]4848+4949+ safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")5050+5151+ var archivePrefix string5252+ if prefix != "" {5353+ archivePrefix = prefix5454+ } else {5555+ archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename)5656+ }5757+5858+ filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename)5959+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))6060+ w.Header().Set("Content-Type", "application/gzip")6161+6262+ gw := gzip.NewWriter(w)6363+ defer gw.Close()6464+6565+ err = gr.WriteTar(gw, archivePrefix)6666+ if err != nil {6767+ // once we start writing to the body we can't report error anymore6868+ // so we are only left with logging the error6969+ x.Logger.Error("writing tar file", "error", err.Error())7070+ return7171+ }7272+7373+ err = gw.Flush()7474+ if err != nil {7575+ // once we start writing to the body we can't report error anymore7676+ // so we are only left with logging the error7777+ x.Logger.Error("flushing", "error", err.Error())7878+ return7979+ }8080+}
+150
knotserver/xrpc/repo_blob.go
···11+package xrpc22+33+import (44+ "crypto/sha256"55+ "encoding/base64"66+ "encoding/json"77+ "fmt"88+ "net/http"99+ "path/filepath"1010+ "slices"1111+ "strings"1212+1313+ "tangled.sh/tangled.sh/core/api/tangled"1414+ "tangled.sh/tangled.sh/core/knotserver/git"1515+ xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"1616+)1717+1818+func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {1919+ _, repoPath, ref, err := x.parseStandardParams(r)2020+ if err != nil {2121+ writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)2222+ return2323+ }2424+2525+ treePath := r.URL.Query().Get("path")2626+ if treePath == "" {2727+ writeError(w, xrpcerr.NewXrpcError(2828+ xrpcerr.WithTag("InvalidRequest"),2929+ xrpcerr.WithMessage("missing path parameter"),3030+ ), http.StatusBadRequest)3131+ return3232+ }3333+3434+ raw := r.URL.Query().Get("raw") == "true"3535+3636+ gr, err := git.Open(repoPath, ref)3737+ if err != nil {3838+ writeError(w, xrpcerr.NewXrpcError(3939+ xrpcerr.WithTag("RefNotFound"),4040+ xrpcerr.WithMessage("repository or ref not found"),4141+ ), http.StatusNotFound)4242+ return4343+ }4444+4545+ contents, err := gr.RawContent(treePath)4646+ if err != nil {4747+ x.Logger.Error("file content", "error", err.Error())4848+ writeError(w, xrpcerr.NewXrpcError(4949+ xrpcerr.WithTag("FileNotFound"),5050+ xrpcerr.WithMessage("file not found at the specified path"),5151+ ), http.StatusNotFound)5252+ return5353+ }5454+5555+ mimeType := http.DetectContentType(contents)5656+5757+ if filepath.Ext(treePath) == ".svg" {5858+ mimeType = "image/svg+xml"5959+ }6060+6161+ if raw {6262+ contentHash := sha256.Sum256(contents)6363+ eTag := fmt.Sprintf("\"%x\"", contentHash)6464+6565+ switch {6666+ case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):6767+ if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {6868+ w.WriteHeader(http.StatusNotModified)6969+ return7070+ }7171+ w.Header().Set("ETag", eTag)7272+7373+ case strings.HasPrefix(mimeType, "text/"):7474+ w.Header().Set("Cache-Control", "public, no-cache")7575+ // serve all text content as text/plain7676+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")7777+7878+ case isTextualMimeType(mimeType):7979+ // handle textual application types (json, xml, etc.) as text/plain8080+ w.Header().Set("Cache-Control", "public, no-cache")8181+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")8282+8383+ default:8484+ x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)8585+ writeError(w, xrpcerr.NewXrpcError(8686+ xrpcerr.WithTag("InvalidRequest"),8787+ xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),8888+ ), http.StatusForbidden)8989+ return9090+ }9191+ w.Write(contents)9292+ return9393+ }9494+9595+ isTextual := func(mt string) bool {9696+ return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)9797+ }9898+9999+ var content string100100+ var encoding string101101+102102+ isBinary := !isTextual(mimeType)103103+104104+ if isBinary {105105+ content = base64.StdEncoding.EncodeToString(contents)106106+ encoding = "base64"107107+ } else {108108+ content = string(contents)109109+ encoding = "utf-8"110110+ }111111+112112+ response := tangled.RepoBlob_Output{113113+ Ref: ref,114114+ Path: treePath,115115+ Content: content,116116+ Encoding: &encoding,117117+ Size: &[]int64{int64(len(contents))}[0],118118+ IsBinary: &isBinary,119119+ }120120+121121+ if mimeType != "" {122122+ response.MimeType = &mimeType123123+ }124124+125125+ w.Header().Set("Content-Type", "application/json")126126+ if err := json.NewEncoder(w).Encode(response); err != nil {127127+ x.Logger.Error("failed to encode response", "error", err)128128+ writeError(w, xrpcerr.NewXrpcError(129129+ xrpcerr.WithTag("InternalServerError"),130130+ xrpcerr.WithMessage("failed to encode response"),131131+ ), http.StatusInternalServerError)132132+ return133133+ }134134+}135135+136136+// isTextualMimeType returns true if the MIME type represents textual content137137+// that should be served as text/plain for security reasons138138+func isTextualMimeType(mimeType string) bool {139139+ textualTypes := []string{140140+ "application/json",141141+ "application/xml",142142+ "application/yaml",143143+ "application/x-yaml",144144+ "application/toml",145145+ "application/javascript",146146+ "application/ecmascript",147147+ }148148+149149+ return slices.Contains(textualTypes, mimeType)150150+}