Skip to content

Commit e22c596

Browse files
committed
fix: resolve hostnames against CIDR allowlist, proxy raw content for copy button, silence .well-known noise
Three bugs found during manual testing: 1. CIDR allowlist DNS resolution: Allowlist.Allows() now resolves hostnames when CIDRs are configured, so hosts like intranet.local that resolve to 10.x IPs match a 10.0.0.0/8 allowlist entry. DNS errors deny access (security-safe default). 2. Copy Source CORS: The "Copy as Markdown" button now fetches through /_cooked/raw/{upstream} instead of directly from upstream, avoiding cross-origin browser blocks. The new handler reuses the same validation pipeline (URL parse, allowlist, SSRF). 3. .well-known log noise: Added a silent 404 handler for /.well-known/{path} so Chrome's automatic traffic-advice requests don't pollute logs with warn-level bad-request entries.
1 parent 7a853f9 commit e22c596

12 files changed

Lines changed: 133 additions & 14 deletions

File tree

.beads/issues.jsonl

Lines changed: 6 additions & 0 deletions
Large diffs are not rendered by default.

internal/fetch/cached.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ func (cc *CachedClient) Store(key string, entry cache.Entry) {
8181
cc.cache.Put(key, entry)
8282
}
8383

84+
// Client returns the underlying fetch client for direct (uncached) access.
85+
func (cc *CachedClient) Client() *Client {
86+
return cc.client
87+
}
88+
8489
// Cache returns the underlying cache for direct access.
8590
func (cc *CachedClient) Cache() *cache.Cache {
8691
return cc.cache

internal/server/allowlist.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package server
22

33
import (
4+
"context"
45
"net"
56
"strings"
67
)
@@ -14,6 +15,7 @@ type Allowlist struct {
1415
cidrs []*net.IPNet
1516
wildcards []string // stored as ".suffix" (e.g. ".internal" from "*.internal")
1617
exact []string // lowercased hostnames
18+
resolver func(ctx context.Context, host string) ([]net.IPAddr, error)
1719
}
1820

1921
// ParseAllowlist parses a comma-separated allowlist string into a structured
@@ -28,7 +30,9 @@ func ParseAllowlist(raw string) *Allowlist {
2830
return nil
2931
}
3032

31-
a := &Allowlist{}
33+
a := &Allowlist{
34+
resolver: net.DefaultResolver.LookupIPAddr,
35+
}
3236
for _, entry := range strings.Split(raw, ",") {
3337
entry = strings.TrimSpace(entry)
3438
if entry == "" {
@@ -79,11 +83,27 @@ func (a *Allowlist) Allows(host string) bool {
7983
}
8084
}
8185

82-
// Check CIDRs (only for IP-literal hosts)
83-
if ip := net.ParseIP(hostname); ip != nil {
84-
for _, cidr := range a.cidrs {
85-
if cidr.Contains(ip) {
86-
return true
86+
// Check CIDRs
87+
if len(a.cidrs) > 0 {
88+
if ip := net.ParseIP(hostname); ip != nil {
89+
// IP-literal host: check directly
90+
for _, cidr := range a.cidrs {
91+
if cidr.Contains(ip) {
92+
return true
93+
}
94+
}
95+
} else if a.resolver != nil {
96+
// Hostname: resolve and check each IP
97+
addrs, err := a.resolver(context.Background(), hostname)
98+
if err != nil {
99+
return false // deny on DNS failure
100+
}
101+
for _, addr := range addrs {
102+
for _, cidr := range a.cidrs {
103+
if cidr.Contains(addr.IP) {
104+
return true
105+
}
106+
}
87107
}
88108
}
89109
}

internal/server/allowlist_test.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package server
22

33
import (
4+
"context"
5+
"fmt"
6+
"net"
47
"testing"
58
)
69

@@ -76,7 +79,8 @@ func TestAllowlist_CIDR(t *testing.T) {
7679
// With port
7780
{"10.0.0.1:8080", true},
7881

79-
// Hostname (not IP) should not match CIDR
82+
// Hostname — real DNS resolution may or may not match CIDR;
83+
// for deterministic testing of hostname resolution, see TestAllowlist_CIDRResolvesHostname
8084
{"example.com", false},
8185
}
8286

@@ -90,6 +94,43 @@ func TestAllowlist_CIDR(t *testing.T) {
9094
}
9195
}
9296

97+
func TestAllowlist_CIDRResolvesHostname(t *testing.T) {
98+
a := ParseAllowlist("10.0.0.0/8")
99+
// Inject a fake resolver that returns 10.0.0.42 for "intranet.local"
100+
a.resolver = func(_ context.Context, host string) ([]net.IPAddr, error) {
101+
if host == "intranet.local" {
102+
return []net.IPAddr{{IP: net.ParseIP("10.0.0.42")}}, nil
103+
}
104+
return nil, fmt.Errorf("no such host")
105+
}
106+
107+
tests := []struct {
108+
host string
109+
wantOK bool
110+
}{
111+
// Hostname resolving to 10.x should match CIDR
112+
{"intranet.local", true},
113+
{"intranet.local:8080", true},
114+
115+
// IP literal still works
116+
{"10.0.0.1", true},
117+
118+
// Unknown host — resolver returns error → deny
119+
{"unknown.host", false},
120+
121+
// IP outside CIDR
122+
{"11.0.0.1", false},
123+
}
124+
125+
for _, tc := range tests {
126+
t.Run(tc.host, func(t *testing.T) {
127+
if got := a.Allows(tc.host); got != tc.wantOK {
128+
t.Errorf("Allows(%q) = %v, want %v", tc.host, got, tc.wantOK)
129+
}
130+
})
131+
}
132+
}
133+
93134
func TestAllowlist_CIDRIPv6(t *testing.T) {
94135
a := ParseAllowlist("fd00::/8")
95136

internal/server/server.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"net/url"
1010
"strconv"
11+
"strings"
1112
"time"
1213

1314
"github.com/air-gapped/cooked/internal/cache"
@@ -88,7 +89,9 @@ func New(cfg *config.Config, version string, assets fs.FS, extraFetchOpts ...fet
8889
func (s *Server) routes() {
8990
s.mux.HandleFunc("GET /healthz", s.handleHealthz)
9091
s.mux.HandleFunc("GET /_cooked/docs", s.handleDocs)
92+
s.mux.HandleFunc("GET /_cooked/raw/{upstream...}", s.handleRaw)
9193
s.mux.HandleFunc("GET /_cooked/{path...}", s.handleAsset)
94+
s.mux.HandleFunc("GET /.well-known/{path...}", s.handleWellKnown)
9295
s.mux.HandleFunc("GET /{$}", s.handleLanding)
9396
s.mux.HandleFunc("GET /{upstream...}", s.handleRender)
9497
}
@@ -179,6 +182,50 @@ func (s *Server) handleAsset(w http.ResponseWriter, r *http.Request) {
179182
w.Write(data)
180183
}
181184

185+
func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
186+
rawUpstream := ExtractUpstreamFromPath(
187+
strings.TrimPrefix(r.URL.Path, "/_cooked/raw"),
188+
r.URL.RawQuery,
189+
)
190+
191+
upstream, err := ParseUpstreamURL(rawUpstream)
192+
if err != nil {
193+
http.Error(w, "bad request", http.StatusBadRequest)
194+
return
195+
}
196+
197+
if !s.allowlist.Allows(upstream.Host) {
198+
http.Error(w, "forbidden", http.StatusForbidden)
199+
return
200+
}
201+
202+
if s.allowlist == nil {
203+
private, err := IsPrivateAddress(upstream.Host)
204+
if err != nil || private {
205+
http.Error(w, "forbidden", http.StatusForbidden)
206+
return
207+
}
208+
}
209+
210+
result, err := s.fetcher.Client().Fetch(rawUpstream, "", "")
211+
if err != nil {
212+
http.Error(w, "upstream fetch failed", http.StatusBadGateway)
213+
return
214+
}
215+
216+
if result.StatusCode != 200 {
217+
http.Error(w, fmt.Sprintf("upstream returned %d", result.StatusCode), result.StatusCode)
218+
return
219+
}
220+
221+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
222+
w.Write(result.Body)
223+
}
224+
225+
func (s *Server) handleWellKnown(w http.ResponseWriter, _ *http.Request) {
226+
w.WriteHeader(http.StatusNotFound)
227+
}
228+
182229
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
183230
start := time.Now()
184231

internal/template/scripts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func writeScripts(buf *bytes.Buffer) {
131131
btn.addEventListener('click', function() {
132132
btn.disabled = true;
133133
btn.textContent = 'Fetching\u2026';
134-
fetch(upstreamURL).then(function(r) {
134+
fetch('/_cooked/raw/' + upstreamURL).then(function(r) {
135135
if (!r.ok) throw new Error(r.status);
136136
return r.text();
137137
}).then(function(text) {

testdata/golden/template/code_file.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@
586586
btn.addEventListener('click', function() {
587587
btn.disabled = true;
588588
btn.textContent = 'Fetching\u2026';
589-
fetch(upstreamURL).then(function(r) {
589+
fetch('/_cooked/raw/' + upstreamURL).then(function(r) {
590590
if (!r.ok) throw new Error(r.status);
591591
return r.text();
592592
}).then(function(text) {

testdata/golden/template/error_404.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ <h1>404 Not Found</h1>
330330
btn.addEventListener('click', function() {
331331
btn.disabled = true;
332332
btn.textContent = 'Fetching\u2026';
333-
fetch(upstreamURL).then(function(r) {
333+
fetch('/_cooked/raw/' + upstreamURL).then(function(r) {
334334
if (!r.ok) throw new Error(r.status);
335335
return r.text();
336336
}).then(function(text) {

testdata/golden/template/error_502.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ <h1>502 Bad Gateway</h1>
330330
btn.addEventListener('click', function() {
331331
btn.disabled = true;
332332
btn.textContent = 'Fetching\u2026';
333-
fetch(upstreamURL).then(function(r) {
333+
fetch('/_cooked/raw/' + upstreamURL).then(function(r) {
334334
if (!r.ok) throw new Error(r.status);
335335
return r.text();
336336
}).then(function(text) {

testdata/golden/template/markdown_with_mermaid.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ <h1 id="arch">Architecture</h1>
586586
btn.addEventListener('click', function() {
587587
btn.disabled = true;
588588
btn.textContent = 'Fetching\u2026';
589-
fetch(upstreamURL).then(function(r) {
589+
fetch('/_cooked/raw/' + upstreamURL).then(function(r) {
590590
if (!r.ok) throw new Error(r.status);
591591
return r.text();
592592
}).then(function(text) {

0 commit comments

Comments
 (0)