Commit dbed1606f9

dbed1606f9a09ae3c3c1cdd29f6be3529550016b

parent: 9e99af2a11

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 11:10 UTC

push: downgrade the scheme only for a loopback host

GITBAY_APNS_HOST dropped to plain HTTP wherever it pointed, putting
an ES256 provider token — a bearer credential good for an hour and
for any device under the topic — on the wire in cleartext to any host
someone named. Every fake in the tree is an httptest server, which
always binds loopback, so constraining the drop to loopback costs no
test anything. A host anywhere else keeps HTTPS, and the warning says
so rather than leaving an operator to wonder.

Ref #89
internal/push/apns.go +33 −10
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "log/slog"
11 "net"
1112 "net/http"
1213 "os"
1314 "strconv"
@@ -67,18 +68,40 @@ func NewClient(cfg config.Push) (*Client, error) {
6768// has to drop the scheme too, or every request fails with "server gave
6869// HTTP response to HTTPS client" instead of reaching the fake at all.
6970//
70// The drop is logged rather than silent. config.Push's own doc comment
71// promises a misconfigured [push] fails loudly at startup rather than
72// filling a queue nobody is watching; a stray GITBAY_APNS_HOST on a real
73// instance would otherwise send the provider JWT over cleartext with no
74// sign anything had changed, where the pre-override behaviour at least
75// failed loudly by attempting TLS against a host that cannot answer it.
71// The drop only applies to a host on this machine. The provider token is
72// a bearer credential, valid for an hour and good for any device under
73// the topic; putting it on the wire in cleartext to somewhere else is not
74// a thing the test override should be able to arrange. Every fake in the
75// tree is an httptest server, which always binds loopback, so nothing
76// loses anything by the restriction. Either way the decision is logged,
77// so an operator who set the variable learns what it did.
7678func apnsScheme() string {
77 if h := os.Getenv("GITBAY_APNS_HOST"); h != "" {
78 slog.Warn("push: GITBAY_APNS_HOST is set, sending to it over plain HTTP instead of APNs", "host", h)
79 return "http"
79 h := os.Getenv("GITBAY_APNS_HOST")
80 if h == "" {
81 return "https"
8082 }
81 return "https"
83 if !loopbackHost(h) {
84 slog.Warn("push: GITBAY_APNS_HOST is not on this machine, still sending over HTTPS; the provider token is a bearer credential and does not travel in cleartext", "host", h)
85 return "https"
86 }
87 slog.Warn("push: GITBAY_APNS_HOST is set, sending to it over plain HTTP instead of APNs", "host", h)
88 return "http"
89}
90
91// loopbackHost reports whether a host:port names this machine. The port
92// is optional: config.Push.Host returns a bare hostname for the real
93// endpoints, and the override may or may not carry one.
94func loopbackHost(hostport string) bool {
95 host := hostport
96 if h, _, err := net.SplitHostPort(hostport); err == nil {
97 host = h
98 }
99 host = strings.Trim(host, "[]")
100 if host == "localhost" {
101 return true
102 }
103 ip := net.ParseIP(host)
104 return ip != nil && ip.IsLoopback()
82105}
83106
84107// Send delivers one alert. The returned duration is the server's
internal/push/apns_test.go +24
@@ -148,3 +148,27 @@ func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) {
148148 t.Fatalf("body is %d bytes, want <= %d", len(body), maxBodyBytes)
149149 }
150150}
151
152// The override drops to plain HTTP only for a host on this machine. The
153// provider token is a bearer credential valid for an hour that can push
154// to any device under the topic, so a GITBAY_APNS_HOST aimed anywhere
155// else keeps HTTPS rather than putting it on the wire in cleartext.
156func TestAPNSSchemeDowngradesOnlyOnLoopback(t *testing.T) {
157 for _, tc := range []struct{ host, want string }{
158 {"", "https"},
159 {"127.0.0.1:8080", "http"},
160 {"127.0.0.53:2197", "http"},
161 {"localhost:1234", "http"},
162 {"[::1]:1234", "http"},
163 {"::1", "http"},
164 {"10.0.0.5:2197", "https"},
165 {"apns.example.com", "https"},
166 {"api.push.apple.com:443", "https"},
167 {"not a host", "https"},
168 } {
169 t.Setenv("GITBAY_APNS_HOST", tc.host)
170 if got := apnsScheme(); got != tc.want {
171 t.Errorf("apnsScheme() with host %q = %q, want %q", tc.host, got, tc.want)
172 }
173 }
174}