build(docker): add a hardened container image
6 files changed, +90 −2
- .dockerignore added +4 −0
- Dockerfile added +15 −0
- deploy/compose.yaml added +26 −0
- limit.go modified +15 −1
- main.go modified +13 −1
- unit_test.go modified +17 −0
.dockerignore added
| @@ -0,0 +1,4 @@ | ||
| 1 | +.git | |
| 2 | +gitpocket | |
| 3 | +*.db | |
| 4 | +deploy | |
Dockerfile added
| @@ -0,0 +1,15 @@ | ||
| 1 | +FROM golang:1.27-alpine AS build | |
| 2 | +WORKDIR /src | |
| 3 | +COPY go.mod go.sum ./ | |
| 4 | +RUN go mod download | |
| 5 | +COPY . . | |
| 6 | +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /gitpocket . | |
| 7 | + | |
| 8 | +FROM alpine:3.22 | |
| 9 | +RUN apk add --no-cache git gnupg openssh-keygen ca-certificates | |
| 10 | +COPY --from=build /gitpocket /usr/local/bin/gitpocket | |
| 11 | +ENV HOME=/tmp GOMEMLIMIT=192MiB | |
| 12 | +USER 65532:65532 | |
| 13 | +EXPOSE 3200 | |
| 14 | +ENTRYPOINT ["/usr/local/bin/gitpocket", "-root", "/srv/gitpocket"] | |
| 15 | +CMD ["serve", "-listen", ":3200"] | |
deploy/compose.yaml added
| @@ -0,0 +1,26 @@ | ||
| 1 | +services: | |
| 2 | + gitpocket: | |
| 3 | + build: .. | |
| 4 | + image: gitpocket | |
| 5 | + command: ["serve", "-listen", ":3200", "-host", "${GITPOCKET_HOST:?set GITPOCKET_HOST}", "-proxy", "172.31.255.1/32"] | |
| 6 | + user: "${GITPOCKET_UID:?set GITPOCKET_UID}:${GITPOCKET_GID:?set GITPOCKET_GID}" | |
| 7 | + ports: | |
| 8 | + - "127.0.0.1:3200:3200" | |
| 9 | + volumes: | |
| 10 | + - /srv/gitpocket:/srv/gitpocket | |
| 11 | + read_only: true | |
| 12 | + tmpfs: | |
| 13 | + - /tmp:size=64m,mode=1777,noexec,nosuid,nodev | |
| 14 | + cap_drop: [ALL] | |
| 15 | + security_opt: | |
| 16 | + - no-new-privileges:true | |
| 17 | + mem_limit: 512m | |
| 18 | + pids_limit: 256 | |
| 19 | + restart: unless-stopped | |
| 20 | + networks: [gitpocket] | |
| 21 | +networks: | |
| 22 | + gitpocket: | |
| 23 | + ipam: | |
| 24 | + config: | |
| 25 | + - subnet: 172.31.255.0/28 | |
| 26 | + gateway: 172.31.255.1 | |
limit.go modified
| @@ -55,12 +55,26 @@ func (l *limiter) fail(key string) { | ||
| 55 | 55 | l.hits[key] = append(l.prune(key, now), now) |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | +var trustedProxies []*net.IPNet | |
| 59 | + | |
| 60 | +func trustedProxy(ip net.IP) bool { | |
| 61 | + if ip.IsLoopback() { | |
| 62 | + return true | |
| 63 | + } | |
| 64 | + for _, n := range trustedProxies { | |
| 65 | + if n.Contains(ip) { | |
| 66 | + return true | |
| 67 | + } | |
| 68 | + } | |
| 69 | + return false | |
| 70 | +} | |
| 71 | + | |
| 58 | 72 | func clientIP(r *http.Request) string { |
| 59 | 73 | host, _, err := net.SplitHostPort(r.RemoteAddr) |
| 60 | 74 | if err != nil { |
| 61 | 75 | host = r.RemoteAddr |
| 62 | 76 | } |
| 63 | - if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { | |
| 77 | + if ip := net.ParseIP(host); ip != nil && trustedProxy(ip) { | |
| 64 | 78 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { |
| 65 | 79 | hops := strings.Split(xff, ",") |
| 66 | 80 | return strings.TrimSpace(hops[len(hops)-1]) |
main.go modified
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "flag" |
| 5 | 5 | "fmt" |
| 6 | 6 | "log" |
| 7 | + "net" | |
| 7 | 8 | "net/http" |
| 8 | 9 | "os" |
| 9 | 10 | "path/filepath" |
| @@ -16,7 +17,7 @@ func main() { | ||
| 16 | 17 | flag.Usage = func() { |
| 17 | 18 | fmt.Fprintln(os.Stderr, `usage: gitpocket [-root dir] command |
| 18 | 19 | |
| 19 | - serve [-listen addr] [-host name] [-name text] [-ssh-user name] run the web server | |
| 20 | + serve [-listen addr] [-host name] [-name text] [-ssh-user name] [-proxy cidr,...] run the web server | |
| 20 | 21 | keys user home type key sshd AuthorizedKeysCommand |
| 21 | 22 | shell user forced command for ssh git access |
| 22 | 23 | invite print a single use registration code |
| @@ -218,7 +219,18 @@ func serve(root string, args []string) { | ||
| 218 | 219 | host := fs.String("host", "localhost", "public hostname used in clone urls") |
| 219 | 220 | name := fs.String("name", "gitpocket", "site name shown in the header") |
| 220 | 221 | sshUser := fs.String("ssh-user", "git", "unix user that serves git over ssh, shown in clone urls") |
| 222 | + proxies := fs.String("proxy", "", "comma separated networks besides loopback whose X-Forwarded-For is trusted") | |
| 221 | 223 | fs.Parse(args) |
| 224 | + for _, cidr := range strings.Split(*proxies, ",") { | |
| 225 | + if cidr = strings.TrimSpace(cidr); cidr == "" { | |
| 226 | + continue | |
| 227 | + } | |
| 228 | + _, n, err := net.ParseCIDR(cidr) | |
| 229 | + if err != nil { | |
| 230 | + log.Fatalf("-proxy %s: %v", cidr, err) | |
| 231 | + } | |
| 232 | + trustedProxies = append(trustedProxies, n) | |
| 233 | + } | |
| 222 | 234 | store := mustStore(root) |
| 223 | 235 | srv := &http.Server{ |
| 224 | 236 | Addr: *listen, |
unit_test.go modified
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "net" | |
| 4 | 5 | "net/http/httptest" |
| 5 | 6 | "strings" |
| 6 | 7 | "testing" |
| @@ -130,3 +131,19 @@ func TestRelativeLinkBaseEscaped(t *testing.T) { | ||
| 130 | 131 | t.Errorf("base broke out of the attribute: %s", out) |
| 131 | 132 | } |
| 132 | 133 | } |
| 134 | + | |
| 135 | +func TestTrustedProxy(t *testing.T) { | |
| 136 | + _, n, _ := net.ParseCIDR("172.31.255.0/28") | |
| 137 | + trustedProxies = []*net.IPNet{n} | |
| 138 | + defer func() { trustedProxies = nil }() | |
| 139 | + r := httptest.NewRequest("GET", "/", nil) | |
| 140 | + r.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.7") | |
| 141 | + r.RemoteAddr = "172.31.255.1:5000" | |
| 142 | + if got := clientIP(r); got != "198.51.100.7" { | |
| 143 | + t.Errorf("trusted proxy hop ignored: %s", got) | |
| 144 | + } | |
| 145 | + r.RemoteAddr = "192.0.2.4:5000" | |
| 146 | + if got := clientIP(r); got != "192.0.2.4" { | |
| 147 | + t.Errorf("untrusted peer could choose its address: %s", got) | |
| 148 | + } | |
| 149 | +} | |