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 ( |
| 8 | 8 | "fmt" |
| 9 | 9 | "io" |
| 10 | 10 | "log/slog" |
| 11 | "net" |
| 11 | 12 | "net/http" |
| 12 | 13 | "os" |
| 13 | 14 | "strconv" |
| @@ -67,18 +68,40 @@ func NewClient(cfg config.Push) (*Client, error) { |
| 67 | 68 | // has to drop the scheme too, or every request fails with "server gave |
| 68 | 69 | // HTTP response to HTTPS client" instead of reaching the fake at all. |
| 69 | 70 | // |
| 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. |
| 76 | 78 | func 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" |
| 80 | 82 | } |
| 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. |
| 94 | func 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() |
| 82 | 105 | } |
| 83 | 106 | |
| 84 | 107 | // 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) { |
| 148 | 148 | t.Fatalf("body is %d bytes, want <= %d", len(body), maxBodyBytes) |
| 149 | 149 | } |
| 150 | 150 | } |
| 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. |
| 156 | func 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 | } |