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.
|
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 | } |