aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-14 09:17:56 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-14 09:17:56 -0800
commitcac85dc06b519d9bd6db4d017d501dffbbd8bac4 (patch)
treedc8024e501c0fbda6b9d28622ff2553475044487
parentca1418fc0135d52a009ab218d6e24187fb355a3c (diff)
downloadneko-cac85dc06b519d9bd6db4d017d501dffbbd8bac4.tar.gz
neko-cac85dc06b519d9bd6db4d017d501dffbbd8bac4.tar.bz2
neko-cac85dc06b519d9bd6db4d017d501dffbbd8bac4.zip
security: mitigate SSRF in image proxy and feed fetcher (fixing NK-0ca7nq)
-rw-r--r--.thicket/tickets.jsonl1
-rw-r--r--internal/crawler/crawler.go11
-rw-r--r--internal/safehttp/safehttp.go110
-rw-r--r--internal/safehttp/safehttp_test.go53
-rw-r--r--models/feed/feed.go8
-rw-r--r--web/web.go5
6 files changed, 171 insertions, 17 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index 4984dec..a91d077 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -66,6 +66,7 @@
{"id":"NK-qwef98","title":"UI Styling: Controls \u0026 Header","description":"","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T18:05:18.450759919Z","updated":"2026-02-13T18:11:46.291830432Z"}
{"id":"NK-r6nhj0","title":"import/export","description":"Import/Export has only ever been partially implemented. Let's finish it up across OPML (de facto standard) but also simple txt line oriented input/output. We may need to file a ticket to deal with the async crawling as part of this.","type":"feature","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-14T16:45:04.739162003Z","updated":"2026-02-14T16:45:04.739162003Z"}
{"id":"NK-ric1zs","title":"Migrate frontend to /api/ endpoints","description":"The backend now provides a clean REST API at /api/. Update the frontend UI to use these new endpoints instead of the legacy backward-compatibility routes (/stream/, /feed/, etc.). This will allow for cleaner separation and better utilization of proper REST patterns.","type":"cleanup","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.864725765Z","updated":"2026-02-13T04:26:55.864725765Z"}
+{"id":"NK-rn4nzp","title":"font themes","description":"in the v2 ui, let's offer a few different font stacks the user can switch through. primarily this should just change font-face, maybe size, but don't worry about colors or anything right now.\n\nthe current default (helvetica neue, palatino)\na fancy all serif stack\na no-nonsense modern san-serif stack\na terminal inspired fixed width stack","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-14T17:10:02.185477382Z","updated":"2026-02-14T17:10:02.185477382Z"}
{"id":"NK-rohuiq","title":"titles changing on read state and hover","description":"Titles are changing on read state from blue to grey. They should just stay blue all the time.\n\nTitles are getting underlined on hover. They should have no underline regardless of hover state.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T03:36:26.36373162Z","updated":"2026-02-14T03:37:50.73870586Z"}
{"id":"NK-shpyxh","title":"add search to new ui","description":"","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T19:29:44.251257089Z","updated":"2026-02-14T01:02:58.547025683Z"}
{"id":"NK-sne5ox","title":"Implement Export/Import UI","description":"Add UI in settings to download OPML export and upload OPML import. Use /export/ and /import/ (need to check if import exists).","type":"epic","status":"icebox","priority":3,"labels":null,"assignee":"","created":"2026-02-13T15:05:23.266731399Z","updated":"2026-02-13T15:05:23.266731399Z"}
diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go
index 10253d8..fce2769 100644
--- a/internal/crawler/crawler.go
+++ b/internal/crawler/crawler.go
@@ -6,6 +6,7 @@ import (
"net/http"
"time"
+ "adammathes.com/neko/internal/safehttp"
"adammathes.com/neko/internal/vlog"
"adammathes.com/neko/models/feed"
"adammathes.com/neko/models/item"
@@ -58,10 +59,7 @@ func GetFeedContent(feedURL string) string {
// n := time.Duration(rand.Int63n(3))
// time.Sleep(n * time.Second)
- c := &http.Client{
- // give up after 5 seconds
- Timeout: 5 * time.Second,
- }
+ c := safehttp.NewSafeClient(5 * time.Second)
request, err := http.NewRequest("GET", feedURL, nil)
if err != nil {
@@ -100,10 +98,7 @@ func GetFeedContent(feedURL string) string {
TODO: sanitize input on crawl
*/
func CrawlFeed(f *feed.Feed, ch chan<- string) {
- c := &http.Client{
- // give up after 5 seconds
- Timeout: 5 * time.Second,
- }
+ c := safehttp.NewSafeClient(5 * time.Second)
fp := gofeed.NewParser()
fp.Client = c
diff --git a/internal/safehttp/safehttp.go b/internal/safehttp/safehttp.go
new file mode 100644
index 0000000..cfc70f1
--- /dev/null
+++ b/internal/safehttp/safehttp.go
@@ -0,0 +1,110 @@
+package safehttp
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+)
+
+var privateIPBlocks []*net.IPNet
+
+func init() {
+ for _, cidr := range []string{
+ "127.0.0.0/8", // IPv4 loopback
+ "10.0.0.0/8", // RFC1918
+ "172.16.0.0/12", // RFC1918
+ "192.168.0.0/16", // RFC1918
+ "169.254.0.0/16", // IPv4 link-local
+ "::1/128", // IPv6 loopback
+ "fe80::/10", // IPv6 link-local
+ "fc00::/7", // IPv6 unique local addr
+ } {
+ _, block, _ := net.ParseCIDR(cidr)
+ privateIPBlocks = append(privateIPBlocks, block)
+ }
+}
+
+func isPrivateIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+ for _, block := range privateIPBlocks {
+ if block.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+func SafeDialer(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) {
+ return func(ctx context.Context, network, address string) (net.Conn, error) {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ host = address
+ }
+
+ if ip := net.ParseIP(host); ip != nil {
+ if isPrivateIP(ip) {
+ return nil, fmt.Errorf("connection to private IP %s is not allowed", ip)
+ }
+ } else {
+ ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, ip := range ips {
+ if isPrivateIP(ip) {
+ return nil, fmt.Errorf("connection to private IP %s is not allowed", ip)
+ }
+ }
+ }
+
+ return dialer.DialContext(ctx, network, address)
+ }
+}
+
+func NewSafeClient(timeout time.Duration) *http.Client {
+ dialer := &net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.DialContext = SafeDialer(dialer)
+
+ return &http.Client{
+ Timeout: timeout,
+ Transport: transport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return fmt.Errorf("too many redirects")
+ }
+
+ host, _, err := net.SplitHostPort(req.URL.Host)
+ if err != nil {
+ host = req.URL.Host
+ }
+
+ if ip := net.ParseIP(host); ip != nil {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("redirect to private IP %s is not allowed", ip)
+ }
+ } else {
+ ips, err := net.DefaultResolver.LookupIP(req.Context(), "ip", host)
+ if err != nil {
+ return err
+ }
+
+ for _, ip := range ips {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("redirect to private IP %s is not allowed", ip)
+ }
+ }
+ }
+ return nil
+ },
+ }
+}
diff --git a/internal/safehttp/safehttp_test.go b/internal/safehttp/safehttp_test.go
new file mode 100644
index 0000000..b2636da
--- /dev/null
+++ b/internal/safehttp/safehttp_test.go
@@ -0,0 +1,53 @@
+package safehttp
+
+import (
+ "net"
+ "testing"
+ "time"
+)
+
+func TestSafeClient(t *testing.T) {
+ client := NewSafeClient(2 * time.Second)
+
+ // Localhost should fail
+ t.Log("Testing localhost...")
+ _, err := client.Get("http://127.0.0.1:8080")
+ if err == nil {
+ t.Error("Expected error for localhost request, got nil")
+ } else {
+ t.Logf("Got expected error: %v", err)
+ }
+
+ // Private IP should fail
+ t.Log("Testing private IP...")
+ _, err = client.Get("http://10.0.0.1")
+ if err == nil {
+ t.Error("Expected error for private IP request, got nil")
+ } else {
+ t.Logf("Got expected error: %v", err)
+ }
+}
+
+func TestIsPrivateIP(t *testing.T) {
+ tests := []struct {
+ ip string
+ expected bool
+ }{
+ {"127.0.0.1", true},
+ {"10.0.0.1", true},
+ {"172.16.0.1", true},
+ {"192.168.1.1", true},
+ {"169.254.1.1", true},
+ {"8.8.8.8", false},
+ {"1.1.1.1", false},
+ {"::1", true},
+ {"fe80::1", true},
+ {"fc00::1", true},
+ }
+
+ for _, tc := range tests {
+ if res := isPrivateIP(net.ParseIP(tc.ip)); res != tc.expected {
+ t.Errorf("isPrivateIP(%s) = %v, want %v", tc.ip, res, tc.expected)
+ }
+ }
+}
diff --git a/models/feed/feed.go b/models/feed/feed.go
index 95e7104..800e47c 100644
--- a/models/feed/feed.go
+++ b/models/feed/feed.go
@@ -6,6 +6,7 @@ import (
"strings"
"time"
+ "adammathes.com/neko/internal/safehttp"
"adammathes.com/neko/models"
"github.com/PuerkitoBio/goquery"
)
@@ -120,12 +121,7 @@ func (f *Feed) Create() error {
// Given a string `url`, return to the best guess of the feed
func ResolveFeedURL(url string) string {
- c := &http.Client{
- Timeout: 10 * http.DefaultClient.Timeout,
- }
- if c.Timeout == 0 {
- c.Timeout = 10 * time.Second
- }
+ c := safehttp.NewSafeClient(10 * time.Second)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
diff --git a/web/web.go b/web/web.go
index 3c53edf..4868577 100644
--- a/web/web.go
+++ b/web/web.go
@@ -20,6 +20,7 @@ import (
"adammathes.com/neko/api"
"adammathes.com/neko/config"
+ "adammathes.com/neko/internal/safehttp"
"golang.org/x/crypto/bcrypt"
)
@@ -74,9 +75,7 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) {
}
// grab the img
- c := &http.Client{
- Timeout: 5 * time.Second,
- }
+ c := safehttp.NewSafeClient(5 * time.Second)
request, err := http.NewRequest("GET", string(decodedURL), nil)
if err != nil {