Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/transport_test.go

Line attribution on main · 177 line(s)

701232cf
Keenan Nemetz
12d ago
1 package web
2
3 // What the browser is told about the transport, where generated links point, and
4 // what happens when too many pages want git at once.
5
6 import (
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
d37d301f
Keenan Nemetz
17h ago
13 "rodent.software/vole/forge/config"
701232cf
Keenan Nemetz
12d ago
14 )
15
268b9368
Keenan Nemetz
19h ago
16 // The SSH link has to name a host a person can connect to. ssh_addr is a bind
17 // address, so a configured public_url names the instance and the listener only
18 // contributes the port.
19 func TestSSHURLUsesTheConfiguredHost(t *testing.T) {
20 t.Parallel()
21 cases := []struct {
22 name string
23 addr string
24 public string
25 wantPrefix string
26 }{
27 {"wildcard bind, configured name", "0.0.0.0:2222", "https://rodent.software", "ssh://git@rodent.software:2222/"},
28 {"default port is not written", "0.0.0.0:22", "https://forge.example.com", "ssh://git@forge.example.com/"},
29 {"no public_url falls back to the listener", "127.0.0.1:2222", "", "ssh://git@127.0.0.1:2222/"},
30 {"ipv6 listener keeps its brackets", "[::1]:2222", "", "ssh://git@[::1]:2222/"},
31 }
32 for _, c := range cases {
33 c := c
34 t.Run(c.name, func(t *testing.T) {
35 t.Parallel()
36 w := &Web{Cfg: &config.Config{SSHAddr: c.addr, PublicURL: c.public}}
37 if got, want := w.sshURL("demo"), c.wantPrefix+"demo.git"; got != want {
38 t.Fatalf("sshURL = %q, want %q", got, want)
39 }
40 })
41 }
42 }
43
701232cf
Keenan Nemetz
12d ago
44 // TestCloneURLPrefersTheConfiguredOrigin: a link the browser hands to a terminal
45 // must not be a link an attacker chose with a Host header.
46 func TestCloneURLPrefersTheConfiguredOrigin(t *testing.T) {
47 t.Parallel()
48 w := &Web{Cfg: &config.Config{PublicURL: "https://forge.example.com"}}
ed03b3d1
Keenan Nemetz
4h ago
49 r := httptest.NewRequest("GET", "http://internal:8080/demo", nil)
701232cf
Keenan Nemetz
12d ago
50 r.Host = "evil.example"
51 r.Header.Set("X-Forwarded-Proto", "http")
52 r.Header.Set("X-Forwarded-Host", "evil.example")
ed03b3d1
Keenan Nemetz
4h ago
53 if got, want := w.cloneURL(r, "demo"), "https://forge.example.com/demo.git"; got != want {
701232cf
Keenan Nemetz
12d ago
54 t.Fatalf("cloneURL = %q, want %q", got, want)
55 }
56 }
57
58 // Without a public_url the origin is all there is. The forwarding header is only
59 // believed when the instance says a proxy is in front of it.
60 func TestCloneURLFallsBackToTheRequestOrigin(t *testing.T) {
61 t.Parallel()
ed03b3d1
Keenan Nemetz
4h ago
62 r := httptest.NewRequest("GET", "http://127.0.0.1:8080/demo", nil)
701232cf
Keenan Nemetz
12d ago
63 r.Host = "127.0.0.1:8080"
64 r.Header.Set("X-Forwarded-Proto", "https")
65
66 w := &Web{Cfg: &config.Config{}}
ed03b3d1
Keenan Nemetz
4h ago
67 if got, want := w.cloneURL(r, "demo"), "http://127.0.0.1:8080/demo.git"; got != want {
701232cf
Keenan Nemetz
12d ago
68 t.Fatalf("cloneURL without a proxy = %q, want %q", got, want)
69 }
70 w = &Web{Cfg: &config.Config{GitHTTPClientIPHeader: "X-Forwarded-For"}}
ed03b3d1
Keenan Nemetz
4h ago
71 if got, want := w.cloneURL(r, "demo"), "https://127.0.0.1:8080/demo.git"; got != want {
701232cf
Keenan Nemetz
12d ago
72 t.Fatalf("cloneURL behind a declared proxy = %q, want %q", got, want)
73 }
74 }
75
76 // A site link follows the forge's scheme: an http link from an https page is a
77 // mixed-content warning, and the site is not actually served over http.
78 func TestSiteURLFollowsTheForgeScheme(t *testing.T) {
79 t.Parallel()
80 r := httptest.NewRequest("GET", "http://forge.example.com/", nil)
81 cases := []struct {
82 public string
83 want string
84 }{
85 {"", "http://demo.forge.example.com"},
86 {"https://forge.example.com", "https://demo.forge.example.com"},
87 }
88 for _, c := range cases {
89 w := &Web{Cfg: &config.Config{PublicURL: c.public, PagesDomain: "forge.example.com"}}
90 if got := w.siteURL(r, "demo"); got != c.want {
91 t.Fatalf("public_url %q: siteURL = %q, want %q", c.public, got, c.want)
92 }
93 }
94 if got := (&Web{Cfg: &config.Config{}}).siteURL(r, "demo"); got != "" {
95 t.Fatalf("sites are disabled but siteURL = %q", got)
96 }
97 }
98
99 func TestSecurityHeadersCloseTheSurface(t *testing.T) {
100 t.Parallel()
101 w := &Web{Cfg: &config.Config{PublicURL: "https://forge.example.com"}}
102 rw := httptest.NewRecorder()
103 w.securityHeaders(rw, httptest.NewRequest("GET", "https://forge.example.com/", nil))
104
105 csp := rw.Header().Get("Content-Security-Policy")
106 for _, want := range []string{"script-src 'self'", "style-src 'self'", "object-src 'none'", "base-uri 'none'"} {
107 if !strings.Contains(csp, want) {
108 t.Fatalf("CSP is missing %q: %s", want, csp)
109 }
110 }
111 if strings.Contains(csp, "unsafe-inline") {
112 t.Fatalf("CSP still allows inline styles or scripts: %s", csp)
113 }
114 for header, want := range map[string]string{
115 "Permissions-Policy": "camera=()",
116 "Cross-Origin-Opener-Policy": "same-origin",
117 "Cross-Origin-Resource-Policy": "same-origin",
118 } {
119 if got := rw.Header().Get(header); !strings.Contains(got, want) {
120 t.Fatalf("%s = %q, want it to contain %q", header, got, want)
121 }
122 }
123 }
124
125 // TestGitSlotRefusesWhenSaturated: the pool is a bound, so a page past it is told
126 // to come back rather than queued behind an unbounded number of readers.
127 func TestGitSlotRefusesWhenSaturated(t *testing.T) {
128 t.Parallel()
129 w := &Web{Cfg: &config.Config{}, gitSem: make(chan struct{}, 1), gitWait: 20 * time.Millisecond}
ed03b3d1
Keenan Nemetz
4h ago
130 release, ok := w.gitSlot(httptest.NewRecorder(), httptest.NewRequest("GET", "/demo/search", nil))
701232cf
Keenan Nemetz
12d ago
131 if !ok {
132 t.Fatal("the first page could not take a slot")
133 }
134 rw := httptest.NewRecorder()
ed03b3d1
Keenan Nemetz
4h ago
135 if _, ok := w.gitSlot(rw, httptest.NewRequest("GET", "/demo/search", nil)); ok {
701232cf
Keenan Nemetz
12d ago
136 t.Fatal("a page past the pool was accepted")
137 }
138 if rw.Code != http.StatusServiceUnavailable {
139 t.Fatalf("status = %d, want 503", rw.Code)
140 }
141 if rw.Header().Get("Retry-After") == "" {
142 t.Fatal("a busy answer without Retry-After")
143 }
144 release()
ed03b3d1
Keenan Nemetz
4h ago
145 if _, ok := w.gitSlot(httptest.NewRecorder(), httptest.NewRequest("GET", "/demo/search", nil)); !ok {
701232cf
Keenan Nemetz
12d ago
146 t.Fatal("a released slot was not reusable")
147 }
148 }
149
150 // sameOriginRequest is the check beside the CSRF token: a request that names
151 // another origin is refused even with the right token.
152 func TestSameOriginRequest(t *testing.T) {
153 t.Parallel()
154 w := &Web{Cfg: &config.Config{PublicURL: "https://forge.example.com"}}
155 r := httptest.NewRequest("POST", "https://forge.example.com/logout", nil)
156
157 if !w.sameOriginRequest(r) {
158 t.Fatal("a request with no Origin or Referer was refused")
159 }
160 r.Header.Set("Origin", "https://forge.example.com")
161 if !w.sameOriginRequest(r) {
162 t.Fatal("same-origin request was refused")
163 }
164 r.Header.Set("Origin", "https://evil.example")
165 if w.sameOriginRequest(r) {
166 t.Fatal("a cross-origin request was accepted")
167 }
168 r.Header.Del("Origin")
169 r.Header.Set("Referer", "https://evil.example/page")
170 if w.sameOriginRequest(r) {
171 t.Fatal("a cross-origin referrer was accepted")
172 }
173 r.Header.Set("Origin", "null")
174 if w.sameOriginRequest(r) {
175 t.Fatal("the opaque origin was accepted")
176 }
177 }