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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 6.4 KiB
1package 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
6import (
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "rodent.software/vole/forge/config"
14)
15
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.
19func 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
44// TestCloneURLPrefersTheConfiguredOrigin: a link the browser hands to a terminal
45// must not be a link an attacker chose with a Host header.
46func TestCloneURLPrefersTheConfiguredOrigin(t *testing.T) {
47 t.Parallel()
48 w := &Web{Cfg: &config.Config{PublicURL: "https://forge.example.com"}}
49 r := httptest.NewRequest("GET", "http://internal:8080/demo", nil)
50 r.Host = "evil.example"
51 r.Header.Set("X-Forwarded-Proto", "http")
52 r.Header.Set("X-Forwarded-Host", "evil.example")
53 if got, want := w.cloneURL(r, "demo"), "https://forge.example.com/demo.git"; got != want {
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.
60func TestCloneURLFallsBackToTheRequestOrigin(t *testing.T) {
61 t.Parallel()
62 r := httptest.NewRequest("GET", "http://127.0.0.1:8080/demo", nil)
63 r.Host = "127.0.0.1:8080"
64 r.Header.Set("X-Forwarded-Proto", "https")
65
66 w := &Web{Cfg: &config.Config{}}
67 if got, want := w.cloneURL(r, "demo"), "http://127.0.0.1:8080/demo.git"; got != want {
68 t.Fatalf("cloneURL without a proxy = %q, want %q", got, want)
69 }
70 w = &Web{Cfg: &config.Config{GitHTTPClientIPHeader: "X-Forwarded-For"}}
71 if got, want := w.cloneURL(r, "demo"), "https://127.0.0.1:8080/demo.git"; got != want {
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.
78func 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
99func 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.
127func TestGitSlotRefusesWhenSaturated(t *testing.T) {
128 t.Parallel()
129 w := &Web{Cfg: &config.Config{}, gitSem: make(chan struct{}, 1), gitWait: 20 * time.Millisecond}
130 release, ok := w.gitSlot(httptest.NewRecorder(), httptest.NewRequest("GET", "/demo/search", nil))
131 if !ok {
132 t.Fatal("the first page could not take a slot")
133 }
134 rw := httptest.NewRecorder()
135 if _, ok := w.gitSlot(rw, httptest.NewRequest("GET", "/demo/search", nil)); ok {
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()
145 if _, ok := w.gitSlot(httptest.NewRecorder(), httptest.NewRequest("GET", "/demo/search", nil)); !ok {
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.
152func 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}
178