diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..458a84e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,56 @@ +name: Build and Test + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run tests + run: go test -v ./... + + - name: Build + run: go build -v ./... + + - name: Run security scan (gosec) + run: | + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec ./... + + build-all: + name: Build All Platforms + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Needed for git describe + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build all platforms + run: bash go-build-all.sh + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: binaries + path: build/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a2330ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build all platforms + run: bash go-build-all.sh + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: build/* + draft: true + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..56069ba --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,59 @@ +name: Security Scan + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + schedule: + # Run security scan daily at 2 AM UTC + - cron: '0 2 * * *' + +permissions: + contents: read + security-events: write + +jobs: + gosec: + name: Gosec Security Scanner + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: '-fmt sarif -out gosec-results.sarif ./...' + + - name: Upload Gosec results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: gosec-results.sarif + + codeql: + name: CodeQL Analysis + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index e949455..72927ab 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.so *.dylib *.zip +proxy4plex # Test binary, built with `go test -c` *.test diff --git a/Dockerfile b/Dockerfile index 41b6a13..fe9c4ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.17-alpine +FROM golang:1.24-alpine LABEL maintainer="Kadrim " diff --git a/README.md b/README.md index d83271d..b6c89bc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Beware: Sideloading (i.e. installing the app on the TV) does not work this way, ## Compiling -At the time of writing, this package needs at least [golang](https://golang.org/) v1.17 +At the time of writing, this package needs at least [golang](https://golang.org/) v1.24 To compile a binary for your currently running system, simply run this command: diff --git a/go.mod b/go.mod index e6c8c2d..0965f83 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/kadrim/proxy4plex -go 1.17 +go 1.24 diff --git a/proxy.go b/proxy.go index 5eff9bf..f8f3b74 100644 --- a/proxy.go +++ b/proxy.go @@ -2,17 +2,43 @@ package main import ( "log" + "net" "net/http" "net/http/httputil" "net/url" "strconv" "strings" + "time" ) type transport struct { http.RoundTripper } +func isLocalOrPrivate(hostname string) bool { + host, _, err := net.SplitHostPort(hostname) + if err != nil { + host = hostname + } + + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return true + } + + ips, err := net.LookupIP(host) + if err != nil { + return true + } + + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() { + return true + } + } + + return false +} + func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { resp, err = t.RoundTripper.RoundTrip(req) if err != nil { @@ -24,8 +50,8 @@ func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error case 301: fallthrough case 302: - redirectURL, _ := url.Parse(resp.Header.Get("Location")) - if redirectURL.Scheme == "https" { + redirectURL, err := url.Parse(resp.Header.Get("Location")) + if err == nil && redirectURL.Scheme == "https" { resp.Header.Set("Location", "http://"+req.Header.Get("X-Forwarded-Host")+"?url="+resp.Header.Get("Location")) } } @@ -35,18 +61,47 @@ func (t *transport) RoundTrip(req *http.Request) (resp *http.Response, err error // handle a request send it to the server func handleRequest(res http.ResponseWriter, req *http.Request) { // get the request URI - server, _ := url.Parse("https://" + host) - reqURL, _ := url.Parse(req.RequestURI) + server, err := url.Parse("https://" + host) + if err != nil { + http.Error(res, "Invalid server URL", http.StatusInternalServerError) + return + } + + reqURL, err := url.Parse(req.RequestURI) + if err != nil { + http.Error(res, "Invalid request URI", http.StatusBadRequest) + return + } + if reqURL.Query().Get("url") != "" { // special proxy handling // extract the GET-Param url - server, _ = url.Parse(reqURL.Query().Get("url")) + targetURL := reqURL.Query().Get("url") + server, err = url.Parse(targetURL) + if err != nil { + http.Error(res, "Invalid target URL", http.StatusBadRequest) + return + } + + if server.Scheme != "https" && server.Scheme != "http" { + http.Error(res, "Invalid URL scheme", http.StatusBadRequest) + return + } + + if isLocalOrPrivate(server.Host) { + http.Error(res, "Access to private addresses is not allowed", http.StatusForbidden) + return + } // replace request req.URL = server req.RequestURI = "" // mux host - server, _ = url.Parse(server.Scheme + "://" + server.Host) + server, err = url.Parse(server.Scheme + "://" + server.Host) + if err != nil { + http.Error(res, "Invalid server URL", http.StatusInternalServerError) + return + } } // prepare reverse proxy @@ -72,15 +127,17 @@ func handleRequest(res http.ResponseWriter, req *http.Request) { func runProxy(disableSideloading bool) { // handle simple information path http.HandleFunc("/info", func(res http.ResponseWriter, req *http.Request) { - res.Write([]byte("The Plex proxy service is running on " + req.Host)) + res.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = res.Write([]byte("The Plex proxy service is running on " + req.Host)) }) //handle widgetlist for sideloading http.HandleFunc("/widgetlist.xml", func(res http.ResponseWriter, req *http.Request) { buf, err := retreiveZipFile() if err != nil { + res.Header().Set("Content-Type", "text/plain; charset=utf-8") res.WriteHeader(http.StatusInternalServerError) - res.Write([]byte(err.Error())) + _, _ = res.Write([]byte(err.Error())) log.Println(err) return } @@ -96,34 +153,54 @@ func runProxy(disableSideloading bool) { ` - res.Write([]byte(xml)) + res.Header().Set("Content-Type", "application/xml; charset=utf-8") + _, _ = res.Write([]byte(xml)) }) // handle app-deployment http.HandleFunc("/"+modifiedAppFile, func(res http.ResponseWriter, req *http.Request) { buf, err := retreiveZipFile() if err != nil { + res.Header().Set("Content-Type", "text/plain; charset=utf-8") res.WriteHeader(http.StatusInternalServerError) - res.Write([]byte(err.Error())) + _, _ = res.Write([]byte(err.Error())) log.Println(err) return } // write the http-response - res.Write(buf) + res.Header().Set("Content-Type", "application/zip") + _, _ = res.Write(buf) }) // start real proxy http.HandleFunc("/", handleRequest) + serverMain := &http.Server{ + Addr: ":" + port, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + } + // try to handle everything on port 80 aswell for serving the app // Note: this will not work on non-rooted android because only high-ports can be used go func() { if !disableSideloading { log.Println("Trying to start app-deployer on port 80 ...") - http.ListenAndServe(":80", nil) + server80 := &http.Server{ + Addr: ":80", + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + } + if err := server80.ListenAndServe(); err != nil { + log.Printf("Port 80 server error (this is expected on non-rooted systems): %v\n", err) + } } }() log.Println("Server starting on Port " + port + " ...") - log.Fatal(http.ListenAndServe(":"+port, nil)) + log.Fatal(serverMain.ListenAndServe()) } diff --git a/zip.go b/zip.go index 5dc9f9c..0915e28 100644 --- a/zip.go +++ b/zip.go @@ -79,7 +79,7 @@ func retreiveZipFile() ([]byte, error) { if download { // write zipData to local file for caching - err := os.WriteFile(officialAppFile, zipData, 0664) + err := os.WriteFile(officialAppFile, zipData, 0600) if err != nil { log.Println("could not save downloaded file, going on anyway") }