Add support for attaching files to reports (#14)

* Create report dir before reading POST body

... so that we can stream uploaded files straight to disk, instead of reading
them into memory first.

* Add support for attaching files to reports

If a 'file' parameter is provided, save it as a file on disk. Also include a
link to it from the github issue.
This commit is contained in:
Richard van der Hoff 2017-05-02 17:53:12 +01:00 committed by GitHub
parent 9bbdf64e5f
commit 530fcd69db
4 changed files with 254 additions and 42 deletions

View File

@ -64,6 +64,13 @@ logs.)
Compressed logs are not supported for the JSON upload encoding. Compressed logs are not supported for the JSON upload encoding.
* `file`: an arbitrary file to attach to the report. Saved as-is to disk, and
a link is added to the github issue. The filename must be in the format
`name.ext`, where `name` contains only alphanumerics, `-` or `_`, and `ext`
is one of `jpg`, `png`, or `txt`.
Not supported for the JSON upload encoding.
* Any other form field names are interpreted as arbitrary name/value strings to * Any other form field names are interpreted as arbitrary name/value strings to
include in the `details.log.gz` file. include in the `details.log.gz` file.

View File

@ -76,13 +76,53 @@ func serveFile(w http.ResponseWriter, r *http.Request, path string) {
return return
} }
// if it's a directory, or doesn't look like a gzip, serve as normal // for anti-XSS belt-and-braces, set a very restrictive CSP
if d.IsDir() || !strings.HasSuffix(path, ".gz") { w.Header().Set("Content-Security-Policy", "default-src: none")
// if it's a directory, serve a listing
if d.IsDir() {
log.Println("Serving", path) log.Println("Serving", path)
http.ServeFile(w, r, path) http.ServeFile(w, r, path)
return return
} }
// if it's a gzipped log file, serve it as text
if strings.HasSuffix(path, ".gz") {
serveGzippedFile(w, r, path, d.Size())
return
}
// otherwise, limit ourselves to a number of known-safe content-types, to
// guard against XSS vulnerabilities.
// http.serveFile preserves the content-type header if one is already set.
w.Header().Set("Content-Type", extensionToMimeType(path))
http.ServeFile(w, r, path)
}
// extensionToMimeType returns a suitable mime type for the given filename
//
// Unlike mime.TypeByExtension, the results are limited to a set of types which
// should be safe to serve to a browser without introducing XSS vulnerabilities.
func extensionToMimeType(path string) string {
if strings.HasSuffix(path, ".txt") {
// anyone uploading text in anything other than utf-8 needs to be
// re-educated.
return "text/plain; charset=utf-8"
}
if strings.HasSuffix(path, ".png") {
return "image/png"
}
if strings.HasSuffix(path, ".jpg") {
return "image/jpeg"
}
return "application/octet-stream"
}
func serveGzippedFile(w http.ResponseWriter, r *http.Request, path string, size int64) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
acceptsGzip := false acceptsGzip := false
@ -97,7 +137,7 @@ func serveFile(w http.ResponseWriter, r *http.Request, path string) {
} }
if acceptsGzip { if acceptsGzip {
serveGzip(w, r, path, d.Size()) serveGzip(w, r, path, size)
} else { } else {
serveUngzipped(w, r, path) serveUngzipped(w, r, path)
} }

View File

@ -31,6 +31,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -57,6 +58,7 @@ type payload struct {
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
Logs []logEntry `json:"logs"` Logs []logEntry `json:"logs"`
Data map[string]string `json:"data"` Data map[string]string `json:"data"`
Files []string
} }
type logEntry struct { type logEntry struct {
@ -83,13 +85,32 @@ func (s *submitServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return return
} }
p := parseRequest(w, req) // create the report dir before parsing the request, so that we can dump
if p == nil { // files straight in
// parseRequest already wrote an error t := time.Now().UTC()
prefix := t.Format("2006-01-02/150405")
reportDir := filepath.Join("bugs", prefix)
if err := os.MkdirAll(reportDir, os.ModePerm); err != nil {
log.Println("Unable to create report directory", err)
http.Error(w, "Internal error", 500)
return return
} }
resp, err := s.saveReport(req.Context(), *p) listingURL := s.apiPrefix + "/listing/" + prefix
log.Println("Handling report submission; listing URI will be", listingURL)
p := parseRequest(w, req, reportDir)
if p == nil {
// parseRequest already wrote an error, but now let's delete the
// useless report dir
if err := os.RemoveAll(reportDir); err != nil {
log.Printf("Unable to remove report dir %s after invalid upload: %v\n",
reportDir, err)
}
return
}
resp, err := s.saveReport(req.Context(), *p, reportDir, listingURL)
if err != nil { if err != nil {
log.Println("Error handling report", err) log.Println("Error handling report", err)
http.Error(w, "Internal error", 500) http.Error(w, "Internal error", 500)
@ -103,7 +124,7 @@ func (s *submitServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// parseRequest attempts to parse a received request as a bug report. If // parseRequest attempts to parse a received request as a bug report. If
// the request cannot be parsed, it responds with an error and returns nil. // the request cannot be parsed, it responds with an error and returns nil.
func parseRequest(w http.ResponseWriter, req *http.Request) *payload { func parseRequest(w http.ResponseWriter, req *http.Request, reportDir string) *payload {
length, err := strconv.Atoi(req.Header.Get("Content-Length")) length, err := strconv.Atoi(req.Header.Get("Content-Length"))
if err != nil { if err != nil {
log.Println("Couldn't parse content-length", err) log.Println("Couldn't parse content-length", err)
@ -120,7 +141,7 @@ func parseRequest(w http.ResponseWriter, req *http.Request) *payload {
if contentType != "" { if contentType != "" {
d, _, _ := mime.ParseMediaType(contentType) d, _, _ := mime.ParseMediaType(contentType)
if d == "multipart/form-data" { if d == "multipart/form-data" {
p, err1 := parseMultipartRequest(w, req) p, err1 := parseMultipartRequest(w, req, reportDir)
if err1 != nil { if err1 != nil {
log.Println("Error parsing multipart data", err1) log.Println("Error parsing multipart data", err1)
http.Error(w, "Bad multipart data", 400) http.Error(w, "Bad multipart data", 400)
@ -177,7 +198,7 @@ func parseJSONRequest(w http.ResponseWriter, req *http.Request) (*payload, error
return &p, nil return &p, nil
} }
func parseMultipartRequest(w http.ResponseWriter, req *http.Request) (*payload, error) { func parseMultipartRequest(w http.ResponseWriter, req *http.Request, reportDir string) (*payload, error) {
rdr, err := req.MultipartReader() rdr, err := req.MultipartReader()
if err != nil { if err != nil {
return nil, err return nil, err
@ -196,14 +217,14 @@ func parseMultipartRequest(w http.ResponseWriter, req *http.Request) (*payload,
return nil, err return nil, err
} }
if err = parseFormPart(part, &p); err != nil { if err = parseFormPart(part, &p, reportDir); err != nil {
return nil, err return nil, err
} }
} }
return &p, nil return &p, nil
} }
func parseFormPart(part *multipart.Part, p *payload) error { func parseFormPart(part *multipart.Part, p *payload, reportDir string) error {
defer part.Close() defer part.Close()
field := part.FormName() field := part.FormName()
@ -220,6 +241,16 @@ func parseFormPart(part *multipart.Part, p *payload) error {
// read the field data directly from the multipart part // read the field data directly from the multipart part
partReader = part partReader = part
} }
if field == "file" {
leafName, err := saveFormPart(part.FileName(), partReader, reportDir)
if err != nil {
return err
}
p.Files = append(p.Files, leafName)
return nil
}
b, err := ioutil.ReadAll(partReader) b, err := ioutil.ReadAll(partReader)
if err != nil { if err != nil {
return err return err
@ -235,6 +266,8 @@ func parseFormPart(part *multipart.Part, p *payload) error {
} else if field == "user_agent" { } else if field == "user_agent" {
p.UserAgent = data p.UserAgent = data
} else if field == "log" || field == "compressed-log" { } else if field == "log" || field == "compressed-log" {
// todo: we could save the log directly rather than pointlessly
// unzipping and re-zipping.
p.Logs = append(p.Logs, logEntry{ p.Logs = append(p.Logs, logEntry{
ID: part.FileName(), ID: part.FileName(),
Lines: data, Lines: data,
@ -245,20 +278,48 @@ func parseFormPart(part *multipart.Part, p *payload) error {
return nil return nil
} }
func (s *submitServer) saveReport(ctx context.Context, p payload) (*submitResponse, error) { // we use a quite restrictive regexp for the filenames; in particular:
//
// * a limited set of extensions. We are careful to limit the content-types
// we will serve the files with, but somebody might accidentally point an
// Apache or nginx at the upload directory, which would serve js files as
// application/javascript and open XSS vulnerabilities.
//
// * no silly characters (/, ctrl chars, etc)
//
// * nothing starting with '.'
var filenameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_-]+\.(jpg|png|txt)$`)
// saveFormPart saves a file upload to the report directory.
//
// Returns the leafname of the saved file.
func saveFormPart(leafName string, reader io.Reader, reportDir string) (string, error) {
if !filenameRegexp.MatchString(leafName) {
return "", fmt.Errorf("Invalid upload filename")
}
fullName := filepath.Join(reportDir, leafName)
log.Println("Saving uploaded file", leafName, "to", fullName)
f, err := os.Create(fullName)
if err != nil {
return "", err
}
defer f.Close()
_, err = io.Copy(f, reader)
if err != nil {
return "", err
}
return leafName, nil
}
func (s *submitServer) saveReport(ctx context.Context, p payload, reportDir, listingURL string) (*submitResponse, error) {
resp := submitResponse{} resp := submitResponse{}
// Dump bug report to disk as form:
// "bugreport-20170115-112233.log.gz" => user text, version, user agent, # logs
// "bugreport-20170115-112233-0.log.gz" => most recent log
// "bugreport-20170115-112233-1.log.gz" => ...
// "bugreport-20170115-112233-N.log.gz" => oldest log
t := time.Now().UTC()
prefix := t.Format("2006-01-02/150405")
listingURL := s.apiPrefix + "/listing/" + prefix
log.Println("Handling report submission; listing URI will be", listingURL)
var summaryBuf bytes.Buffer var summaryBuf bytes.Buffer
fmt.Fprintf( fmt.Fprintf(
&summaryBuf, &summaryBuf,
@ -268,12 +329,12 @@ func (s *submitServer) saveReport(ctx context.Context, p payload) (*submitRespon
for k, v := range p.Data { for k, v := range p.Data {
fmt.Fprintf(&summaryBuf, "%s: %s\n", k, v) fmt.Fprintf(&summaryBuf, "%s: %s\n", k, v)
} }
if err := gzipAndSave(summaryBuf.Bytes(), prefix, "details.log.gz"); err != nil { if err := gzipAndSave(summaryBuf.Bytes(), reportDir, "details.log.gz"); err != nil {
return nil, err return nil, err
} }
for i, log := range p.Logs { for i, log := range p.Logs {
if err := gzipAndSave([]byte(log.Lines), prefix, fmt.Sprintf("logs-%04d.log.gz", i)); err != nil { if err := gzipAndSave([]byte(log.Lines), reportDir, fmt.Sprintf("logs-%04d.log.gz", i)); err != nil {
return nil, err // TODO: Rollback? return nil, err // TODO: Rollback?
} }
} }
@ -331,9 +392,18 @@ func buildGithubIssueRequest(p payload, listingURL string) github.IssueRequest {
if p.Version != "" { if p.Version != "" {
fmt.Fprintf(&bodyBuf, "Version: `%s`\n", p.Version) fmt.Fprintf(&bodyBuf, "Version: `%s`\n", p.Version)
} }
fmt.Fprintf(&bodyBuf, "[Logs](%s)\n", listingURL) fmt.Fprintf(&bodyBuf, "[Logs](%s)", listingURL)
body := bodyBuf.String()
for _, file := range p.Files {
fmt.Fprintf(
&bodyBuf,
" / [%s](%s)",
file,
listingURL+"/"+file,
)
}
body := bodyBuf.String()
return github.IssueRequest{ return github.IssueRequest{
Title: &title, Title: &title,
Body: &body, Body: &body,
@ -346,8 +416,7 @@ func respond(code int, w http.ResponseWriter) {
} }
func gzipAndSave(data []byte, dirname, fpath string) error { func gzipAndSave(data []byte, dirname, fpath string) error {
_ = os.MkdirAll(filepath.Join("bugs", dirname), os.ModePerm) fpath = filepath.Join(dirname, fpath)
fpath = filepath.Join("bugs", dirname, fpath)
if _, err := os.Stat(fpath); err == nil { if _, err := os.Stat(fpath); err == nil {
return fmt.Errorf("file already exists") // the user can just retry return fmt.Errorf("file already exists") // the user can just retry

View File

@ -17,8 +17,11 @@ limitations under the License.
package main package main
import ( import (
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@ -26,7 +29,10 @@ import (
// testParsePayload builds a /submit request with the given body, and calls // testParsePayload builds a /submit request with the given body, and calls
// parseRequest with it. // parseRequest with it.
func testParsePayload(t *testing.T, body, contentType string) (p *payload) { //
// if tempDir is empty, a new temp dir is created, and deleted when the test
// completes.
func testParsePayload(t *testing.T, body, contentType string, tempDir string) (*payload, *http.Response) {
req, err := http.NewRequest("POST", "/api/submit", strings.NewReader(body)) req, err := http.NewRequest("POST", "/api/submit", strings.NewReader(body))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -35,20 +41,26 @@ func testParsePayload(t *testing.T, body, contentType string) (p *payload) {
if contentType != "" { if contentType != "" {
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
} }
rr := httptest.NewRecorder()
p = parseRequest(rr, req)
if p == nil { // temporary dir for the uploaded files
t.Error("parseRequest returned nil") if tempDir == "" {
tempDir = mkTempDir(t)
defer os.RemoveAll(tempDir)
} }
return
rr := httptest.NewRecorder()
p := parseRequest(rr, req, tempDir)
return p, rr.Result()
} }
func TestEmptyJson(t *testing.T) { func TestEmptyJson(t *testing.T) {
body := "{}" body := "{}"
// we just test it is parsed without errors for now // we just test it is parsed without errors for now
testParsePayload(t, body, "application/json") p, _ := testParsePayload(t, body, "application/json", "")
if p == nil {
t.Fatal("parseRequest returned nil")
}
} }
// check that we can unpick the json submitted by the android clients // check that we can unpick the json submitted by the android clients
@ -57,7 +69,10 @@ func TestUnpickAndroidMangling(t *testing.T) {
"version": "User : @ylc8001:matrix.org\nPhone : Lenovo P2a42\nVector version: 0:6:9\n", "version": "User : @ylc8001:matrix.org\nPhone : Lenovo P2a42\nVector version: 0:6:9\n",
"user_agent": "Android" "user_agent": "Android"
}` }`
p := testParsePayload(t, body, "") p, _ := testParsePayload(t, body, "", "")
if p == nil {
t.Fatal("parseRequest returned nil")
}
if p.Text != "test ylc 001" { if p.Text != "test ylc 001" {
t.Errorf("user text: got %s, want %s", p.Text, "test ylc 001") t.Errorf("user text: got %s, want %s", p.Text, "test ylc 001")
} }
@ -79,7 +94,35 @@ func TestUnpickAndroidMangling(t *testing.T) {
} }
func TestMultipartUpload(t *testing.T) { func TestMultipartUpload(t *testing.T) {
body := `------WebKitFormBoundarySsdgl8Nq9voFyhdO reportDir := mkTempDir(t)
defer os.RemoveAll(reportDir)
p, _ := testParsePayload(t, multipartBody(),
"multipart/form-data; boundary=----WebKitFormBoundarySsdgl8Nq9voFyhdO",
reportDir,
)
if p == nil {
t.Fatal("parseRequest returned nil")
}
checkParsedMultipartUpload(t, p)
// check file uploaded correctly
dat, err := ioutil.ReadFile(filepath.Join(reportDir, "passwd.txt"))
if err != nil {
t.Error("unable to read uploaded file", err)
} else {
datstr := string(dat)
wanted := "bibblybobbly"
if datstr != wanted {
t.Errorf("File contents: got %s, want %s", datstr, wanted)
}
}
}
func multipartBody() (body string) {
body = `------WebKitFormBoundarySsdgl8Nq9voFyhdO
Content-Disposition: form-data; name="text" Content-Disposition: form-data; name="text"
test words. test words.
@ -121,10 +164,21 @@ Content-Type: application/octet-stream
body += string([]byte{ body += string([]byte{
0x1f, 0x8b, 0x08, 0x00, 0xbf, 0xd8, 0xf5, 0x58, 0x00, 0x03, 0x1f, 0x8b, 0x08, 0x00, 0xbf, 0xd8, 0xf5, 0x58, 0x00, 0x03,
0x2b, 0x49, 0x2d, 0x2e, 0xe1, 0x02, 0x00, 0x2b, 0x49, 0x2d, 0x2e, 0xe1, 0x02, 0x00,
0xc6, 0x35, 0xb9, 0x3b, 0x05, 0x00, 0x00, 0x00}) 0xc6, 0x35, 0xb9, 0x3b, 0x05, 0x00, 0x00, 0x00,
body += "\n------WebKitFormBoundarySsdgl8Nq9voFyhdO--\n" 0x0a,
})
p := testParsePayload(t, body, "multipart/form-data; boundary=----WebKitFormBoundarySsdgl8Nq9voFyhdO") body += `------WebKitFormBoundarySsdgl8Nq9voFyhdO
Content-Disposition: form-data; name="file"; filename="passwd.txt"
Content-Type: application/octet-stream
bibblybobbly
`
body += "------WebKitFormBoundarySsdgl8Nq9voFyhdO--\n"
return
}
func checkParsedMultipartUpload(t *testing.T, p *payload) {
wanted := "test words." wanted := "test words."
if p.Text != wanted { if p.Text != wanted {
t.Errorf("User text: got %s, want %s", p.Text, wanted) t.Errorf("User text: got %s, want %s", p.Text, wanted)
@ -160,3 +214,45 @@ Content-Type: application/octet-stream
t.Errorf("Log 2: got %s, want %s", p.Logs[2].Lines, wanted) t.Errorf("Log 2: got %s, want %s", p.Logs[2].Lines, wanted)
} }
} }
func TestEmptyFilename(t *testing.T) {
body := `------WebKitFormBoundarySsdgl8Nq9voFyhdO
Content-Disposition: form-data; name="file"
file
------WebKitFormBoundarySsdgl8Nq9voFyhdO--
`
p, resp := testParsePayload(t, body, "multipart/form-data; boundary=----WebKitFormBoundarySsdgl8Nq9voFyhdO", "")
if p != nil {
t.Error("parsePayload accepted upload with no filename")
}
if resp.StatusCode != 400 {
t.Errorf("response code: got %v, want %v", resp.StatusCode, 400)
}
}
func TestBadFilename(t *testing.T) {
body := `------WebKitFormBoundarySsdgl8Nq9voFyhdO
Content-Disposition: form-data; name="file"; filename="etc/passwd"
file
------WebKitFormBoundarySsdgl8Nq9voFyhdO--
`
p, resp := testParsePayload(t, body, "multipart/form-data; boundary=----WebKitFormBoundarySsdgl8Nq9voFyhdO", "")
if p != nil {
t.Error("parsePayload accepted upload with bad filename")
}
if resp.StatusCode != 400 {
t.Errorf("response code: got %v, want %v", resp.StatusCode, 400)
}
}
func mkTempDir(t *testing.T) string {
td, err := ioutil.TempDir("", "rageshake_test")
if err != nil {
t.Fatal(err)
}
return td
}