From 77e255790e177bdb107ff8504b36e396b7ed3192 Mon Sep 17 00:00:00 2001 From: Christian Mehlmauer Date: Tue, 2 Apr 2019 21:51:15 +0200 Subject: [PATCH] first release --- .gitignore | 4 + .travis.yml | 6 ++ Dockerfile | 26 +++++ Makefile | 46 +++++++++ Readme.md | 80 +++++++++++++++ go.mod | 3 + main.go | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 440 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 Readme.md create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1d4010 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +chrome.json +gochro +*.exe +build diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..04b2b6d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: go + +go: + - master + +script: go build ./... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d2c023 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:latest AS build-env +WORKDIR /src +ENV GO111MODULE=on +COPY go.mod /src/ +RUN go mod download +COPY main.go . +RUN CGO_ENABLED=0 GOOS=linux go build -a -o gochro -ldflags="-s -w" -gcflags="all=-trimpath=/src" -asmflags="all=-trimpath=/src" + +FROM alpine:latest + +RUN apk add --no-cache chromium \ + && rm -rf /var/cache/* + +RUN mkdir -p /app \ + && adduser -D chrome \ + && chown -R chrome:chrome /app + +USER chrome +WORKDIR /app + +ENV CHROME_BIN=/usr/bin/chromium-browser \ + CHROME_PATH=/usr/lib/chromium/ + +COPY --from=build-env /src/gochro . + +ENTRYPOINT [ "./gochro" ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b8851f --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +TARGET=./build +ARCHS=amd64 386 +LDFLAGS="-s -w" +GCFLAGS="all=-trimpath=$(shell pwd)" +ASMFLAGS="all=-trimpath=$(shell pwd)" +PROG=gochro + +.DEFAULT_GOAL := all + +all: clean windows linux darwin + +docker-update: + wget https://raw.githubusercontent.com/jessfraz/dotfiles/master/etc/docker/seccomp/chrome.json -O ./chrome.json + docker pull golang:latest + docker pull alpine:latest + docker build --tag ${PROG}:dev . + +docker-run: docker-update + docker run --rm -p 8000:8000 --security-opt seccomp=chrome.json ${PROG}:dev -host 0.0.0.0:8000 + +docker-run-daemon: docker-update + docker run --rm -d -p 8000:8000 --security-opt seccomp=chrome.json ${PROG}:dev -host 0.0.0.0:8000 + +windows: + @mkdir -p ${TARGET} ; \ + for GOARCH in ${ARCHS}; do \ + echo "Building for windows $${GOARCH} ..." ; \ + GOOS=windows GOARCH=$${GOARCH} go build -ldflags=${LDFLAGS} -gcflags=${GCFLAGS} -asmflags=${ASMFLAGS} -o ${TARGET}/${PROG}-windows-$${GOARCH}.exe ; \ + done; + +linux: + @mkdir -p ${TARGET} ; \ + for GOARCH in ${ARCHS}; do \ + echo "Building for linux $${GOARCH} ..." ; \ + GOOS=linux GOARCH=$${GOARCH} go build -ldflags=${LDFLAGS} -gcflags=${GCFLAGS} -asmflags=${ASMFLAGS} -o ${TARGET}/${PROG}-linux-$${GOARCH} ; \ + done; + +darwin: + @mkdir -p ${TARGET} ; \ + for GOARCH in ${ARCHS}; do \ + echo "Building for darwin $${GOARCH} ..." ; \ + GOOS=darwin GOARCH=$${GOARCH} go build -ldflags=${LDFLAGS} -gcflags=${GCFLAGS} -asmflags=${ASMFLAGS} -o ${TARGET}/${PROG}-darwin-$${GOARCH} ; \ + done; + +clean: + @rm -rf ${TARGET}/* diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..10d976b --- /dev/null +++ b/Readme.md @@ -0,0 +1,80 @@ +# gochro + +goChro is a small docker image with chromium installed and a golang based webserver to interact wit it. It can be used to take screenshots of websites using chromium-headless and convert HTML pages to PDF. + +If errors occur the error will be logged to stdout and a non information leaking error message is presented to the user. + +This project is currently used on [https://wpscan.io](https://wpscan.io) for taking website screenshots and to generate PDF reports. + +## Screenshot + +This URL takes a Screenshot of [https://firefart.at](https://firefart.at) with a resolution of 1024x768 and returns an image. + +[http://localhost:8080/screenshot?url=https://firefart.at&w=1024&h=768](http://localhost:8080/screenshot?url=https://firefart.at&w=1024&h=768) + +## PDF + +Send a POST request with the HTML you want to convert in the Post body to the following url. + +[http://localhost:8080/html2pdf?w=1024&h=768](http://localhost:8080/html2pdf?w=1024&h=768) + +This will return a PDF of the HTML input. + +Example: + +```text +POST /html2pdf?w=1024&h=768 HTTP/1.1 +Host: localhost:8000 +Content-Type: application/x-www-form-urlencoded +Content-Length: 119 + + +Test Page + +

This is a test

+

This is a test

+ + +``` + +## Run server + +To run this image you should use the [seccomp profile](https://github.com/jessfraz/dotfiles/blob/master/etc/docker/seccomp/chrome.json) provided by [Jess Frazelle](https://github.com/jessfraz). The privileges on the host are needed for chromiums internal security sandbox. You can also deactivate the sandbox on chromium (would require changes in `main.go`) but that's a bad idea and puts your server at risk, so please use the seccomp profile instead. + +I included all the necessary steps in the included Makefile to build and run everything + +### Only build the webserver for non docker use + +The following command builds the webserver for non docker use inside the `build` directory + +```bash +make all +``` + +### Only build docker image + +To only build the docker image run + +```bash +make docker-update +``` + +This will download the seccomp profile, all needed base images and builds the `gochro:dev` tagged image. + +### Run the image + +To run the image in interactive mode (docker output will be connected to current terminal) run + +```bash +make docker-run +``` + +This will also build the image before running it. This maps the internal port 8000 to your machine. + +### Run the image in deamon mode + +To run it in deamon mode use the following command. This will launch everything in the background. Be aware that the webserver is rerun on startup of the machine if you don't shut down the container manually. + +```bash +make docker-run-daemon +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7df4b46 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/FireFart/gochro + +go 1.12.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..fc69db1 --- /dev/null +++ b/main.go @@ -0,0 +1,275 @@ +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "math/rand" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "runtime/debug" + "strconv" + "time" +) + +const ( + chromiumPath = "/usr/bin/chromium-browser" +) + +type application struct { + infoLog *log.Logger + errorLog *log.Logger +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randStringRunes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func main() { + host := flag.String("host", "127.0.0.1:8080", "IP and Port to bind to") + flag.Parse() + + infoLog := log.New(os.Stdout, "[INFO]\t", log.Ldate|log.Ltime) + errorLog := log.New(os.Stderr, "[ERROR]\t", log.Ldate|log.Ltime|log.Lshortfile) + + app := &application{ + errorLog: errorLog, + infoLog: infoLog, + } + + srv := &http.Server{ + Addr: *host, + ErrorLog: errorLog, + Handler: app.routes(), + } + app.infoLog.Printf("Starting server on %s", *host) + app.errorLog.Fatal(srv.ListenAndServe()) +} + +func (app *application) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/screenshot", app.errorHandler(app.screenshot)) + mux.HandleFunc("/html2pdf", app.errorHandler(app.html2pdf)) + return app.recoverPanic(app.logRequest(mux)) +} + +func toImage(ctx context.Context, url string, w, h int) ([]byte, error) { + return execChrome(ctx, "screenshot", url, w, h) +} + +func toPDF(ctx context.Context, url string, w, h int) ([]byte, error) { + return execChrome(ctx, "pdf", url, w, h) +} + +func execChrome(ctxMain context.Context, action, url string, w, h int) ([]byte, error) { + args := []string{ + "--headless", + "--disable-gpu", + "--disable-software-rasterizer", + "--timeout=55000", // 55 secs, context timeout is 1 minute + "--disable-dev-shm-usage", + "--hide-scrollbars", + fmt.Sprintf("--window-size=%d,%d", w, h), + } + + switch action { + case "screenshot": + args = append(args, "--screenshot") + case "pdf": + args = append(args, "--print-to-pdf") + default: + return nil, fmt.Errorf("unknown action %q", action) + } + + // last parameter is the url + args = append(args, url) + + tmpdir := path.Join(os.TempDir(), fmt.Sprintf("chrome_%s", randStringRunes(10))) + err := os.Mkdir(tmpdir, os.ModePerm) + if err != nil { + return nil, fmt.Errorf("could not create dir %q %v", tmpdir, err) + } + defer os.RemoveAll(tmpdir) + + ctx, cancel := context.WithTimeout(ctxMain, 1*time.Minute) + defer cancel() + + var out bytes.Buffer + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, chromiumPath, args...) + cmd.Dir = tmpdir + cmd.Stdout = &out + cmd.Stderr = &stderr + err = cmd.Run() + if err != nil { + return nil, fmt.Errorf("could not execute command %v: %s", err, stderr.String()) + } + + var outfile string + + switch action { + case "screenshot": + outfile = path.Join(tmpdir, "screenshot.png") + case "pdf": + outfile = path.Join(tmpdir, "output.pdf") + default: + return nil, fmt.Errorf("unknown action %q", action) + } + + content, err := ioutil.ReadFile(outfile) + if err != nil { + return nil, fmt.Errorf("could not read temp file %v", err) + } + + return content, nil +} + +func (app *application) logError(w http.ResponseWriter, err error, withTrace bool) { + w.Header().Set("Connection", "close") + errorText := fmt.Sprintf("%v", err) + app.errorLog.Println(errorText) + if withTrace { + app.errorLog.Printf("%s", debug.Stack()) + } + http.Error(w, "There was an error processing your request", http.StatusInternalServerError) +} + +func (app *application) logRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + app.infoLog.Printf("%s - %s %s %s", r.RemoteAddr, r.Proto, r.Method, r.URL.RequestURI()) + next.ServeHTTP(w, r) + }) +} + +func (app *application) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + app.logError(w, fmt.Errorf("%s", err), true) + } + }() + next.ServeHTTP(w, r) + }) +} + +func (app *application) errorHandler(h func(*http.Request) (string, []byte, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, b, err := h(r) + if err != nil { + app.logError(w, err, false) + return + } + w.Header().Set("Content-Type", content) + _, err = w.Write(b) + if err != nil { + app.logError(w, err, false) + return + } + } +} + +func getStringParameter(r *http.Request, paramname string) (string, error) { + p, ok := r.URL.Query()[paramname] + if !ok || len(p[0]) < 1 { + return "", fmt.Errorf("missing parameter %s", paramname) + } + return p[0], nil +} + +func getIntParameter(r *http.Request, paramname string) (int, error) { + p, ok := r.URL.Query()[paramname] + if !ok || len(p[0]) < 1 { + return 0, fmt.Errorf("missing parameter %s", paramname) + } + + i, err := strconv.Atoi(p[0]) + if err != nil { + return 0, fmt.Errorf("invalid parameter %s=%q - %v", paramname, p, err) + } else if i < 1 { + return 0, fmt.Errorf("invalid parameter %s: %q", paramname, p) + } + + return i, nil +} + +// http://localhost:8080/screenshot?url=https://firefart.at&w=1024&h=768 +func (app *application) screenshot(r *http.Request) (string, []byte, error) { + url, err := getStringParameter(r, "url") + if err != nil { + return "", nil, err + } + + w, err := getIntParameter(r, "w") + if err != nil { + return "", nil, err + } + + h, err := getIntParameter(r, "h") + if err != nil { + return "", nil, err + } + + content, err := toImage(r.Context(), url, w, h) + if err != nil { + return "", nil, err + } + + return "image/png", content, nil +} + +// http://localhost:8080/html2pdf?w=1024&h=768 +func (app *application) html2pdf(r *http.Request) (string, []byte, error) { + w, err := getIntParameter(r, "w") + if err != nil { + return "", nil, err + } + + h, err := getIntParameter(r, "h") + if err != nil { + return "", nil, err + } + + tmpf, err := ioutil.TempFile("", "pdf.*.html") + if err != nil { + return "", nil, fmt.Errorf("could not create tmp file: %v", err) + } + defer os.Remove(tmpf.Name()) + + bytes, err := io.Copy(tmpf, r.Body) + if err != nil { + return "", nil, fmt.Errorf("could not copy request: %v", err) + } + if bytes <= 0 { + return "", nil, fmt.Errorf("please provide a valid post body") + } + + err = tmpf.Close() + if err != nil { + return "", nil, fmt.Errorf("could not close tmp file: %v", err) + } + + path, err := filepath.Abs(tmpf.Name()) + if err != nil { + return "", nil, fmt.Errorf("could not get temp file path: %v", err) + } + + content, err := toPDF(r.Context(), path, w, h) + if err != nil { + return "", nil, err + } + + return "application/pdf", content, nil +}