aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-14 09:20:40 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-14 09:20:40 -0800
commit08032aab10f0e1429d25ecae1acf6c40d63e9ff4 (patch)
treeb4f89ec2deabb7c6bc3237d300512f1af92ea67c
parent17117617017aba1f29a1f6c8939cdc7c1fd94438 (diff)
downloadneko-08032aab10f0e1429d25ecae1acf6c40d63e9ff4.tar.gz
neko-08032aab10f0e1429d25ecae1acf6c40d63e9ff4.tar.bz2
neko-08032aab10f0e1429d25ecae1acf6c40d63e9ff4.zip
security: add HTTP security headers (fixing NK-7xuajb)
-rw-r--r--.thicket/tickets.jsonl4
-rw-r--r--internal/crawler/crawler_test.go5
-rw-r--r--internal/safehttp/safehttp.go8
-rw-r--r--models/feed/feed_test.go5
-rw-r--r--web/web.go18
-rw-r--r--web/web_test.go25
6 files changed, 61 insertions, 4 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index a91d077..3c2a8fc 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -1,4 +1,4 @@
-{"id":"NK-0ca7nq","title":"[security] Mitigate SSRF in Image Proxy and Feed Fetcher","description":"Restrict outbound HTTP requests to prevent access to internal networks. 1. Create a custom http.Transport for the fetcher clients. 2. In the DialContext, resolve the IP address of the target hostname. 3. Block connections to private IP ranges (RFC 1918) and loopback addresses (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16). 4. Disable following redirects to private IPs.","type":"","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:57.352011404Z","updated":"2026-02-14T16:36:49.362993079Z"}
+{"id":"NK-0ca7nq","title":"[security] Mitigate SSRF in Image Proxy and Feed Fetcher","description":"Restrict outbound HTTP requests to prevent access to internal networks. 1. Create a custom http.Transport for the fetcher clients. 2. In the DialContext, resolve the IP address of the target hostname. 3. Block connections to private IP ranges (RFC 1918) and loopback addresses (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16). 4. Disable following redirects to private IPs.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:57.352011404Z","updated":"2026-02-14T17:18:01.301400136Z"}
{"id":"NK-0nf7hu","title":"Implement Frontend Logout","description":"Add logout button/link in dashboard and call /api/logout.","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:50:46.760744241Z","updated":"2026-02-13T15:28:14.486180285Z"}
{"id":"NK-0ppv3f","title":"Implement Frontend Settings","description":"Create settings page for managing feeds/categories.","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:44:01.631640578Z","updated":"2026-02-13T15:04:12.408401691Z"}
{"id":"NK-13v159","title":"docker compose fails","description":"When running docker compose up I got the following error:\n[2/3] STEP 1/9: FROM golang:1.23-bullseye AS backend-builder\nResolved \"golang\" as an alias (/etc/containers/registries.conf.d/000-shortnames.conf)\nTrying to pull docker.io/library/golang:1.23-bullseye...\nGetting image source signatures\nCopying blob sha256:bb30cea4afcbc0a0405508119c28d87afb4518e3558e2f1fb0a52a0498994287\nCopying blob sha256:a9acb5a6634ff8f020bd4562c483cdd83503103d2c080d87e777643b57123e41\nCopying blob sha256:b26972d9a448e4dba0ac85216372d6ee52bc89839590b4e97f94b77ced5571fe\nCopying blob sha256:b1efd17e5717172aa4463c9c599bce51a6939b602dbb135bf6c26d672a6e7496\nCopying blob sha256:6a887974b056452b76229e3392cbf8513e741cd3dcd1f05e7397eea6fce361a0\nCopying blob sha256:382d65ac76ebcbc7ba7ee0d232ae7afbec48e2b3b673983ac8ced522dabe3abb\nCopying blob sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1\nCopying config sha256:6f1643fb9acc4fc0b9bf95efd1b4b1cd274dcd73a39cc99ef57da203d7088f42\nWriting manifest to image destination\n[2/3] STEP 2/9: RUN go install github.com/GeertJohan/go.rice/rice@latest\ngo: downloading github.com/GeertJohan/go.rice v1.0.3\ngo: downloading github.com/GeertJohan/go.incremental v1.0.0\ngo: downloading github.com/akavel/rsrc v0.8.0\ngo: downloading github.com/daaku/go.zipexe v1.0.2\ngo: downloading github.com/jessevdk/go-flags v1.4.0\ngo: downloading github.com/nkovacs/streamquote v1.0.0\ngo: downloading github.com/valyala/fasttemplate v1.0.1\ngo: downloading github.com/valyala/bytebufferpool v1.0.0\n--\u003e 181d1254149e\n[2/3] STEP 3/9: WORKDIR /app\n--\u003e 3f497307e2ca\n[2/3] STEP 4/9: COPY go.mod go.sum ./\n--\u003e 339c24ca12cd\n[2/3] STEP 5/9: RUN go mod download\ngo: go.mod requires go \u003e= 1.24.2 (running go 1.23.12; GOTOOLCHAIN=local)\nError: building at STEP \"RUN go mod download\": while running runtime: exit status 1","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T01:10:13.257269131Z","updated":"2026-02-14T01:15:05.19401879Z"}
@@ -58,7 +58,7 @@
{"id":"NK-mgmn5m","title":"serve \"legacy\" version UI at /v1/ instead of /","description":"Let's \"softly\" start to deprecated the legacy version by moving it to /v1/ -- ideally this won't require any changes but there may be some relative/absolute URLs to adjust in the static files there or in rouoting","type":"task","status":"open","priority":0,"labels":null,"assignee":"","created":"2026-02-14T16:41:04.710679944Z","updated":"2026-02-14T16:41:04.710679944Z"}
{"id":"NK-mwf9q2","title":"Implement Tag View","description":"Create frontend view for browsing items by tag/category. Use /tag/:id endpoint.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T15:04:12.441165286Z","updated":"2026-02-13T18:04:38.644796168Z"}
{"id":"NK-n7nuyy","title":"Fix TypeScript Lint Errors in Tests","description":"There are lint errors in test files regarding jest-dom matchers (toBeInTheDocument, etc). Ensure proper types are included.","type":"bug","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T21:50:15.140702806Z","updated":"2026-02-13T21:50:15.140702806Z"}
-{"id":"NK-o3n9jf","title":"[security] Run Docker Container as Non-Root User","description":"Update the Dockerfile to create and use a non-privileged user. 1. Create a user (e.g., neko) in the final stage. 2. Ensure the /app/data directory is owned by this user. 3. Switch to this user using USER neko before the CMD.","type":"","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:58.328232962Z","updated":"2026-02-14T16:36:49.406727944Z"}
+{"id":"NK-o3n9jf","title":"[security] Run Docker Container as Non-Root User","description":"Update the Dockerfile to create and use a non-privileged user. 1. Create a user (e.g., neko) in the final stage. 2. Ensure the /app/data directory is owned by this user. 3. Switch to this user using USER neko before the CMD.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:58.328232962Z","updated":"2026-02-14T17:18:34.748238191Z"}
{"id":"NK-ojdcmq","title":"UI: Add skeleton loaders for feed item loading","description":"The currently 'Loading more...' text is basic. We should add skeleton loaders for a smoother infinite scroll experience.","type":"task","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T19:45:07.376295295Z","updated":"2026-02-13T19:45:07.376295295Z"}
{"id":"NK-op5594","title":"Ensure 80% Frontend Test Coverage","description":"Configure coverage reporting in vitest and ensure the frontend codebase maintains at least 80% test coverage.","type":"task","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T05:46:24.13314466Z","updated":"2026-02-13T05:50:46.728239299Z"}
{"id":"NK-p89hyt","title":"make new v2 UI the default and serve at /","description":"After we move the old UI to be served at v1, serve the new UI at /\n\nWe can keep serving it at v2/ as well if we want.","type":"task","status":"open","priority":0,"labels":null,"assignee":"","created":"2026-02-14T16:42:20.13241547Z","updated":"2026-02-14T16:42:20.13241547Z"}
diff --git a/internal/crawler/crawler_test.go b/internal/crawler/crawler_test.go
index e0c4c6b..a8a9c9c 100644
--- a/internal/crawler/crawler_test.go
+++ b/internal/crawler/crawler_test.go
@@ -8,10 +8,15 @@ import (
"testing"
"adammathes.com/neko/config"
+ "adammathes.com/neko/internal/safehttp"
"adammathes.com/neko/models"
"adammathes.com/neko/models/feed"
)
+func init() {
+ safehttp.AllowLocal = true
+}
+
func setupTestDB(t *testing.T) {
t.Helper()
config.Config.DBFile = ":memory:"
diff --git a/internal/safehttp/safehttp.go b/internal/safehttp/safehttp.go
index cfc70f1..e0859c4 100644
--- a/internal/safehttp/safehttp.go
+++ b/internal/safehttp/safehttp.go
@@ -8,7 +8,10 @@ import (
"time"
)
-var privateIPBlocks []*net.IPNet
+var (
+ privateIPBlocks []*net.IPNet
+ AllowLocal bool // For testing
+)
func init() {
for _, cidr := range []string{
@@ -27,6 +30,9 @@ func init() {
}
func isPrivateIP(ip net.IP) bool {
+ if AllowLocal {
+ return false
+ }
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
diff --git a/models/feed/feed_test.go b/models/feed/feed_test.go
index 715a705..700bdeb 100644
--- a/models/feed/feed_test.go
+++ b/models/feed/feed_test.go
@@ -7,9 +7,14 @@ import (
"testing"
"adammathes.com/neko/config"
+ "adammathes.com/neko/internal/safehttp"
"adammathes.com/neko/models"
)
+func init() {
+ safehttp.AllowLocal = true
+}
+
func setupTestDB(t *testing.T) {
t.Helper()
config.Config.DBFile = filepath.Join(t.TempDir(), "test.db")
diff --git a/web/web.go b/web/web.go
index 4868577..11a5831 100644
--- a/web/web.go
+++ b/web/web.go
@@ -259,7 +259,7 @@ func NewRouter(cfg *config.Settings) http.Handler {
mux.Handle("/", GzipMiddleware(AuthWrap(http.HandlerFunc(indexHandler))))
- return CSRFMiddleware(mux)
+ return SecurityHeadersMiddleware(CSRFMiddleware(mux))
}
func Serve(cfg *config.Settings) {
@@ -383,3 +383,19 @@ func CSRFMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
+
+func SecurityHeadersMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("X-XSS-Protection", "1; mode=block")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ // Content-Security-Policy
+ // default-src 'self'
+ // style-src 'self' 'unsafe-inline' (for React/styled-components if used)
+ // img-src 'self' data: * (RSS images can be from anywhere)
+ // connect-src 'self' (API calls)
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: *; connect-src 'self'; frame-ancestors 'none';")
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/web/web_test.go b/web/web_test.go
index 89ca998..c6cf306 100644
--- a/web/web_test.go
+++ b/web/web_test.go
@@ -10,10 +10,15 @@ import (
"adammathes.com/neko/api"
"adammathes.com/neko/config"
+ "adammathes.com/neko/internal/safehttp"
"adammathes.com/neko/models"
"golang.org/x/crypto/bcrypt"
)
+func init() {
+ safehttp.AllowLocal = true
+}
+
func setupTestDB(t *testing.T) {
t.Helper()
config.Config.DBFile = filepath.Join(t.TempDir(), "test.db")
@@ -774,3 +779,23 @@ func TestCSRFMiddleware(t *testing.T) {
t.Errorf("Expected 200 for POST with valid token, got %d", rr.Code)
}
}
+
+func TestSecurityHeadersMiddleware(t *testing.T) {
+ handler := SecurityHeadersMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ if rr.Header().Get("X-Content-Type-Options") != "nosniff" {
+ t.Error("Missing X-Content-Type-Options: nosniff")
+ }
+ if rr.Header().Get("X-Frame-Options") != "DENY" {
+ t.Error("Missing X-Frame-Options: DENY")
+ }
+ if rr.Header().Get("Content-Security-Policy") == "" {
+ t.Error("Missing Content-Security-Policy")
+ }
+}