From 26923136b344700566cb1ea986ac6353d47fdf8a Mon Sep 17 00:00:00 2001 From: rokuosanai <288084358+rokuosanai@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:16:56 +0000 Subject: [PATCH 1/4] feat: configure trusted image hosts --- docs/content/configuration/parameters.md | 7 +++ gic.config.yaml | 4 ++ pkg/config/config_test.go | 33 ++++++++++++ pkg/config/type.go | 29 ++++++++--- pkg/config/validation.go | 17 ++++++ pkg/core/generator.go | 2 +- pkg/core/http_assets.go | 66 +++++++++++++++++++----- pkg/core/http_assets_test.go | 60 +++++++++++++++++++-- 8 files changed, 193 insertions(+), 25 deletions(-) diff --git a/docs/content/configuration/parameters.md b/docs/content/configuration/parameters.md index 220d28a..b97705f 100644 --- a/docs/content/configuration/parameters.md +++ b/docs/content/configuration/parameters.md @@ -25,6 +25,10 @@ output: directory: 'static/images/%Y-%m-%d_%H%M%S' filename: '[:id].png' url: '/images/%Y-%m-%d_%H%M%S' + trusted_hosts: + - 'github.com' + - 'user-images.githubusercontent.com' + - 'private-user-images.githubusercontent.com' ``` ## Configuration Items @@ -52,11 +56,14 @@ Output settings. - `filename`: Image filename - `url`: Image URL referenced from Markdown - `targets`: URL prefixes to detect and replace in issue bodies +- `trusted_hosts`: Exact HTTPS hosts that may receive the GitHub token when images are downloaded If `targets` is omitted, the built-in GitHub attachment URL rules are used. If `targets: []` is specified, no image URLs are detected or replaced. Wildcard host patterns such as `https://*.githubusercontent.com` are also supported. +`trusted_hosts` is an independent security boundary: adding a URL to `targets` does **not** grant it access to the token. Hosts match exactly (including an explicit port), and tokens are sent only over HTTPS. On redirects, the token is removed unless the redirect destination is also listed. If omitted, the default GitHub attachment hosts (`github.com`, `user-images.githubusercontent.com`, and `private-user-images.githubusercontent.com`) are trusted for backward compatibility. Set `trusted_hosts: []` to download every image without a token. + `[:id]` will be replaced with the image ID. The image ID is unique within each issue and assigned sequentially. ## Placeholders diff --git a/gic.config.yaml b/gic.config.yaml index 7c89118..f3f42d1 100644 --- a/gic.config.yaml +++ b/gic.config.yaml @@ -13,6 +13,10 @@ output: targets: - "https://github.com/user-attachments/" - "https://*.githubusercontent.com" + trusted_hosts: + - "github.com" + - "user-images.githubusercontent.com" + - "private-user-images.githubusercontent.com" # For page bundle # output: diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index b754121..a6b0f2f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -8,6 +8,39 @@ import ( "github.com/spf13/viper" ) +func TestOutputImagesConfig_TrustedImageHosts(t *testing.T) { + images := NewOutputImagesConfig() + expected := []string{ + "github.com", + "user-images.githubusercontent.com", + "private-user-images.githubusercontent.com", + } + got := images.TrustedImageHosts() + if len(got) != len(expected) { + t.Fatalf("trusted hosts = %#v, want %#v", got, expected) + } + for i := range expected { + if got[i] != expected[i] { + t.Fatalf("trusted hosts = %#v, want %#v", got, expected) + } + } +} + +func TestOutputImagesConfig_TrustedImageHosts_PreservesExplicitEmptyValue(t *testing.T) { + images := &OutputImagesConfig{TrustedHosts: []string{}} + if got := images.TrustedImageHosts(); len(got) != 0 { + t.Fatalf("trusted hosts = %#v, want empty", got) + } +} + +func TestConfigValidate_RejectsInvalidTrustedImageHosts(t *testing.T) { + conf := NewConfig() + conf.Output.Images.TrustedHosts = []string{"https://github.com"} + if err := conf.validate(); err == nil { + t.Fatal("expected trusted host validation to fail") + } +} + func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { tempDir := t.TempDir() diff --git a/pkg/config/type.go b/pkg/config/type.go index 1d72027..fbfeaa1 100644 --- a/pkg/config/type.go +++ b/pkg/config/type.go @@ -26,10 +26,11 @@ type OutputArticlesConfig struct { } type OutputImagesConfig struct { - Directory string `yaml:"directory" mapstructure:"directory"` - Filename string `yaml:"filename" mapstructure:"filename"` - BaseURL *string `yaml:"url" mapstructure:"url"` - Targets []string `yaml:"targets" mapstructure:"targets"` + Directory string `yaml:"directory" mapstructure:"directory"` + Filename string `yaml:"filename" mapstructure:"filename"` + BaseURL *string `yaml:"url" mapstructure:"url"` + Targets []string `yaml:"targets" mapstructure:"targets"` + TrustedHosts []string `yaml:"trusted_hosts" mapstructure:"trusted_hosts"` } var defaultImageTargets = []string{ @@ -38,6 +39,12 @@ var defaultImageTargets = []string{ "https://private-user-images.githubusercontent.com/", } +var defaultTrustedImageHosts = []string{ + "github.com", + "user-images.githubusercontent.com", + "private-user-images.githubusercontent.com", +} + type HugoConfig struct { Content *HugoContentConfig `yaml:"content,omitempty" mapstructure:"content"` Images *HugoImagesConfig `yaml:"images,omitempty" mapstructure:"images"` @@ -109,9 +116,10 @@ func NewOutputArticlesConfig() *OutputArticlesConfig { func NewOutputImagesConfig() *OutputImagesConfig { url := "/images/%Y-%m-%d_%H%M%S" return &OutputImagesConfig{ - Directory: "static/images/%Y-%m-%d_%H%M%S", - Filename: "[:id].png", - BaseURL: &url, + Directory: "static/images/%Y-%m-%d_%H%M%S", + Filename: "[:id].png", + BaseURL: &url, + TrustedHosts: defaultTrustedImageHosts, } } @@ -129,6 +137,13 @@ func (c *OutputImagesConfig) TargetURLs() []string { return c.Targets } +func (c *OutputImagesConfig) TrustedImageHosts() []string { + if c == nil || c.TrustedHosts == nil { + return defaultTrustedImageHosts + } + return c.TrustedHosts +} + func (c *Config) normalize() { if c.GitHub == nil { c.GitHub = NewGitHubConfig() diff --git a/pkg/config/validation.go b/pkg/config/validation.go index 9cb95c1..dbff533 100644 --- a/pkg/config/validation.go +++ b/pkg/config/validation.go @@ -3,6 +3,8 @@ package config import ( "fmt" "log/slog" + "net/url" + "strings" ) func (c *Config) validate() error { @@ -12,6 +14,7 @@ func (c *Config) validate() error { }{ // Constraints {"Failed to validate deprecated options", c.WarnDeprecatedOptions}, + {"Failed to validate output.images.trusted_hosts", c.ValidTrustedImageHosts}, } // Check @@ -25,6 +28,20 @@ func (c *Config) validate() error { return nil } +func (c *Config) ValidTrustedImageHosts() bool { + if c.Output == nil || c.Output.Images == nil { + return true + } + + for _, host := range c.Output.Images.TrustedImageHosts() { + parsed, err := url.Parse("https://" + host) + if err != nil || host == "" || parsed.Host != host || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Contains(host, "@") { + return false + } + } + return true +} + func (c *Config) WarnDeprecatedOptions() bool { if c.Hugo == nil { return true diff --git a/pkg/core/generator.go b/pkg/core/generator.go index 8a35a09..c8e0e8e 100644 --- a/pkg/core/generator.go +++ b/pkg/core/generator.go @@ -46,7 +46,7 @@ func NewArticleGeneratorWithLogger(conf config.Config, token string, logger *slo return nil, err } - imageRepo := NewHTTPImageRepositoryWithLogger(token, logger) + imageRepo := NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, conf.Output.Images.TrustedImageHosts(), logger) articleRepo := NewFileSystemArticleRepositoryWithLogger(imageRepo, logger) // Initialize services. diff --git a/pkg/core/http_assets.go b/pkg/core/http_assets.go index 2133842..6769e8e 100644 --- a/pkg/core/http_assets.go +++ b/pkg/core/http_assets.go @@ -8,6 +8,7 @@ import ( "log/slog" "mime" "net/http" + "net/url" "strings" "time" ) @@ -16,22 +17,39 @@ const defaultHTTPTimeout = 30 // HTTPImageRepository downloads images over HTTP. type HTTPImageRepository struct { - token string - logger *slog.Logger - client *http.Client + token string + trustedHosts map[string]struct{} + logger *slog.Logger + client *http.Client } -// NewHTTPImageRepository creates a new HTTPImageRepository. +// NewHTTPImageRepository creates a new HTTPImageRepository without trusted hosts. func NewHTTPImageRepository(token string) AssetFetcher { - return NewHTTPImageRepositoryWithLogger(token, nil) + return NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, nil, nil) } -// NewHTTPImageRepositoryWithLogger creates a new HTTPImageRepository with an injected logger. +// NewHTTPImageRepositoryWithTrustedHosts creates a new HTTPImageRepository that only sends a token to trusted HTTPS hosts. +func NewHTTPImageRepositoryWithTrustedHosts(token string, trustedHosts []string) *HTTPImageRepository { + return NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, trustedHosts, nil) +} + +// NewHTTPImageRepositoryWithLogger creates a new HTTPImageRepository with an injected logger and no trusted hosts. func NewHTTPImageRepositoryWithLogger(token string, logger *slog.Logger) AssetFetcher { + return NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, nil, logger) +} + +// NewHTTPImageRepositoryWithTrustedHostsAndLogger creates a new HTTPImageRepository with trusted hosts and an injected logger. +func NewHTTPImageRepositoryWithTrustedHostsAndLogger(token string, trustedHosts []string, logger *slog.Logger) *HTTPImageRepository { + hosts := make(map[string]struct{}, len(trustedHosts)) + for _, host := range trustedHosts { + hosts[strings.ToLower(host)] = struct{}{} + } + return &HTTPImageRepository{ - token: token, - logger: defaultLogger(logger), - client: &http.Client{Timeout: defaultHTTPTimeout * time.Second}, + token: token, + trustedHosts: hosts, + logger: defaultLogger(logger), + client: &http.Client{Timeout: defaultHTTPTimeout * time.Second}, } } @@ -50,8 +68,8 @@ func (r *HTTPImageRepository) Fetch(ctx context.Context, image *Image) (*ImageAs // downloadImage downloads an image over HTTP. func (r *HTTPImageRepository) downloadImage(ctx context.Context, url string) (io.ReadCloser, string, error) { - // Try an authenticated request first so private attachments can be fetched. - if r.token != "" { + // A token is only sent to explicitly trusted HTTPS hosts. + if r.token != "" && r.isTrustedURL(url) { if body, contentType, err := r.sendRequest(ctx, url, true); err == nil { return body, contentType, nil } else { @@ -83,7 +101,22 @@ func (r *HTTPImageRepository) sendRequest(ctx context.Context, url string, inclu req.Header.Set("Authorization", "token "+r.token) } - resp, err := r.client.Do(req) + client := *r.client + if includeToken && r.token != "" { + previousCheckRedirect := client.CheckRedirect + client.CheckRedirect = func(redirectReq *http.Request, via []*http.Request) error { + redirectReq.Header.Del("Authorization") + if r.isTrustedURL(redirectReq.URL.String()) { + redirectReq.Header.Set("Authorization", "token "+r.token) + } + if previousCheckRedirect != nil { + return previousCheckRedirect(redirectReq, via) + } + return nil + } + } + + resp, err := client.Do(req) if err != nil { return nil, "", err } @@ -98,6 +131,15 @@ func (r *HTTPImageRepository) sendRequest(ctx context.Context, url string, inclu return resp.Body, contentType, nil } +func (r *HTTPImageRepository) isTrustedURL(rawURL string) bool { + parsedURL, err := url.Parse(rawURL) + if err != nil || !strings.EqualFold(parsedURL.Scheme, "https") { + return false + } + _, trusted := r.trustedHosts[strings.ToLower(parsedURL.Host)] + return trusted +} + func normalizeContentType(value string) string { mediaType, _, err := mime.ParseMediaType(value) if err != nil { diff --git a/pkg/core/http_assets_test.go b/pkg/core/http_assets_test.go index f6771ff..8e3e590 100644 --- a/pkg/core/http_assets_test.go +++ b/pkg/core/http_assets_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -106,7 +107,7 @@ func TestHTTPImageRepository_Download(t *testing.T) { t.Run("request with token", func(t *testing.T) { var authHeader string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader = r.Header.Get("Authorization") w.Header().Set("Content-Type", "image/png") w.WriteHeader(http.StatusOK) @@ -114,7 +115,10 @@ func TestHTTPImageRepository_Download(t *testing.T) { })) defer server.Close() - repo := NewHTTPImageRepository("test-token") + serverURL, err := url.Parse(server.URL) + assert.NoError(t, err) + repo := NewHTTPImageRepositoryWithTrustedHosts("test-token", []string{serverURL.Host}) + repo.client = server.Client() image := &Image{ URL: server.URL, Time: "2021-01-01_000000", @@ -126,18 +130,64 @@ func TestHTTPImageRepository_Download(t *testing.T) { defer asset.Body.Close() assertEqualCmp(t, "token test-token", authHeader) }) + + t.Run("does not send a token to an untrusted host", func(t *testing.T) { + var authHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("PNG")) + })) + defer server.Close() + + repo := NewHTTPImageRepositoryWithTrustedHosts("test-token", nil) + asset, err := repo.Fetch(context.Background(), NewImage(server.URL, "2021-01-01_000000", 0)) + assert.NoError(t, err) + defer asset.Body.Close() + assert.Empty(t, authHeader) + }) + + t.Run("removes a token when a trusted host redirects to an untrusted host", func(t *testing.T) { + var trustedAuthHeader string + var untrustedAuthHeader string + untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + untrustedAuthHeader = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("PNG")) + })) + defer untrusted.Close() + + trusted := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + trustedAuthHeader = r.Header.Get("Authorization") + http.Redirect(w, r, untrusted.URL, http.StatusFound) + })) + defer trusted.Close() + + trustedURL, err := url.Parse(trusted.URL) + assert.NoError(t, err) + repo := NewHTTPImageRepositoryWithTrustedHosts("test-token", []string{trustedURL.Host}) + repo.client = trusted.Client() + asset, err := repo.Fetch(context.Background(), NewImage(trusted.URL, "2021-01-01_000000", 0)) + assert.NoError(t, err) + defer asset.Body.Close() + assert.Equal(t, "token test-token", trustedAuthHeader) + assert.Empty(t, untrustedAuthHeader) + }) } func TestHTTPImageRepository_Fetch_ReportsAuthenticatedAndFallbackFailures(t *testing.T) { var logs bytes.Buffer logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer server.Close() - repo := NewHTTPImageRepositoryWithLogger("test-token", logger) - _, err := repo.Fetch(context.Background(), NewImage(server.URL, "2021-01-01_000000", 0)) + serverURL, err := url.Parse(server.URL) + assert.NoError(t, err) + repo := NewHTTPImageRepositoryWithTrustedHostsAndLogger("test-token", []string{serverURL.Host}, logger) + repo.client = server.Client() + _, err = repo.Fetch(context.Background(), NewImage(server.URL, "2021-01-01_000000", 0)) assert.Error(t, err) assert.Contains(t, err.Error(), "authenticated request failed") From f5a86642e8a7bef777d6eaf2014f85fa9d5b1ca9 Mon Sep 17 00:00:00 2001 From: rokuosanai <288084358+rokuosanai@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:18:01 +0000 Subject: [PATCH 2/4] test: preserve empty trusted image hosts --- pkg/config/config_test.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a6b0f2f..1661bd8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -67,10 +67,11 @@ func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { Output: &OutputConfig{ Articles: NewOutputArticlesConfig(), Images: &OutputImagesConfig{ - Directory: "static/images", - Filename: "[:id].png", - BaseURL: Ptr("/images"), - Targets: []string{}, + Directory: "static/images", + Filename: "[:id].png", + BaseURL: Ptr("/images"), + Targets: []string{}, + TrustedHosts: []string{}, }, }, } @@ -86,6 +87,9 @@ func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { if !strings.Contains(string(data), "targets: []") { t.Fatalf("expected explicit empty targets in config, got:\n%s", string(data)) } + if !strings.Contains(string(data), "trusted_hosts: []") { + t.Fatalf("expected explicit empty trusted hosts in config, got:\n%s", string(data)) + } reloaded, err := Reload() if err != nil { @@ -100,4 +104,10 @@ func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { if len(reloaded.Output.Images.TargetURLs()) != 0 { t.Fatalf("target urls = %#v", reloaded.Output.Images.TargetURLs()) } + if reloaded.Output.Images.TrustedHosts == nil { + t.Fatal("trusted hosts became nil after reload") + } + if len(reloaded.Output.Images.TrustedImageHosts()) != 0 { + t.Fatalf("trusted hosts = %#v", reloaded.Output.Images.TrustedImageHosts()) + } } From c5b8e40776e3d09cf160c68cec9b1e5b7a0b8eb6 Mon Sep 17 00:00:00 2001 From: rokuosanai <288084358+rokuosanai@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:25:36 +0000 Subject: [PATCH 3/4] fix: handle partial image configuration --- pkg/config/config_test.go | 43 ++++++++++++++++++++++++++++++++++++ pkg/config/type.go | 7 ++++++ pkg/core/generator.go | 2 +- pkg/core/generator_test.go | 6 +++++ pkg/core/http_assets_test.go | 35 +++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1661bd8..7bc4059 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -33,6 +33,13 @@ func TestOutputImagesConfig_TrustedImageHosts_PreservesExplicitEmptyValue(t *tes } } +func TestConfig_TrustedImageHosts_WithPartialConfigUsesDefaults(t *testing.T) { + conf := Config{} + if got := conf.TrustedImageHosts(); len(got) != len(defaultTrustedImageHosts) { + t.Fatalf("trusted hosts = %#v, want defaults", got) + } +} + func TestConfigValidate_RejectsInvalidTrustedImageHosts(t *testing.T) { conf := NewConfig() conf.Output.Images.TrustedHosts = []string{"https://github.com"} @@ -41,6 +48,42 @@ func TestConfigValidate_RejectsInvalidTrustedImageHosts(t *testing.T) { } } +func TestReload_UsesDefaultTrustedImageHostsWhenOmitted(t *testing.T) { + tempDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("restore wd: %v", err) + } + config = Config{} + viper.Reset() + }) + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("chdir: %v", err) + } + config = Config{} + viper.Reset() + + contents := "output:\n images:\n directory: static/images\n filename: '[:id].png'\n url: /images\n" + if err := os.WriteFile(GetConfigPath(), []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + reloaded, err := Reload() + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Output.Images.TrustedHosts != nil { + t.Fatalf("trusted hosts = %#v, want omitted value", reloaded.Output.Images.TrustedHosts) + } + if got := reloaded.TrustedImageHosts(); len(got) != len(defaultTrustedImageHosts) { + t.Fatalf("trusted hosts = %#v, want defaults", got) + } +} + func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { tempDir := t.TempDir() diff --git a/pkg/config/type.go b/pkg/config/type.go index fbfeaa1..416624f 100644 --- a/pkg/config/type.go +++ b/pkg/config/type.go @@ -144,6 +144,13 @@ func (c *OutputImagesConfig) TrustedImageHosts() []string { return c.TrustedHosts } +func (c *Config) TrustedImageHosts() []string { + if c == nil || c.Output == nil { + return defaultTrustedImageHosts + } + return c.Output.Images.TrustedImageHosts() +} + func (c *Config) normalize() { if c.GitHub == nil { c.GitHub = NewGitHubConfig() diff --git a/pkg/core/generator.go b/pkg/core/generator.go index c8e0e8e..6e88b80 100644 --- a/pkg/core/generator.go +++ b/pkg/core/generator.go @@ -46,7 +46,7 @@ func NewArticleGeneratorWithLogger(conf config.Config, token string, logger *slo return nil, err } - imageRepo := NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, conf.Output.Images.TrustedImageHosts(), logger) + imageRepo := NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, conf.TrustedImageHosts(), logger) articleRepo := NewFileSystemArticleRepositoryWithLogger(imageRepo, logger) // Initialize services. diff --git a/pkg/core/generator_test.go b/pkg/core/generator_test.go index 9803459..845e148 100644 --- a/pkg/core/generator_test.go +++ b/pkg/core/generator_test.go @@ -30,6 +30,12 @@ func TestNewArticleGenerator(t *testing.T) { }) } +func TestNewArticleGeneratorWithPartialConfig(t *testing.T) { + gen, err := NewArticleGenerator(config.Config{}, "test-token") + assert.NoError(t, err) + assert.NotNil(t, gen) +} + func TestArticleGenerator_ConvertIssueToArticle(t *testing.T) { conf := *config.NewConfig() conf.Output.Images.BaseURL = Ptr("/images") diff --git a/pkg/core/http_assets_test.go b/pkg/core/http_assets_test.go index 8e3e590..c691c6f 100644 --- a/pkg/core/http_assets_test.go +++ b/pkg/core/http_assets_test.go @@ -175,6 +175,41 @@ func TestHTTPImageRepository_Download(t *testing.T) { }) } +func TestHTTPImageRepository_IsTrustedURL(t *testing.T) { + repo := NewHTTPImageRepositoryWithTrustedHosts("test-token", []string{"Example.COM:8443"}) + + assert.True(t, repo.isTrustedURL("https://example.com:8443/image.png")) + assert.True(t, repo.isTrustedURL("HTTPS://EXAMPLE.COM:8443/image.png")) + assert.False(t, repo.isTrustedURL("http://example.com:8443/image.png")) + assert.False(t, repo.isTrustedURL("https://example.com:8444/image.png")) +} + +func TestHTTPImageRepository_TrustedRedirectRetainsToken(t *testing.T) { + var initialAuthHeader string + var redirectedAuthHeader string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirect" { + initialAuthHeader = r.Header.Get("Authorization") + http.Redirect(w, r, "/image", http.StatusFound) + return + } + redirectedAuthHeader = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("PNG")) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + assert.NoError(t, err) + repo := NewHTTPImageRepositoryWithTrustedHosts("test-token", []string{serverURL.Host}) + repo.client = server.Client() + asset, err := repo.Fetch(context.Background(), NewImage(server.URL+"/redirect", "2021-01-01_000000", 0)) + assert.NoError(t, err) + defer asset.Body.Close() + assert.Equal(t, "token test-token", initialAuthHeader) + assert.Equal(t, "token test-token", redirectedAuthHeader) +} + func TestHTTPImageRepository_Fetch_ReportsAuthenticatedAndFallbackFailures(t *testing.T) { var logs bytes.Buffer logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) From 2d549d459125c494b0a3afc049417a3f87319f1f Mon Sep 17 00:00:00 2001 From: rokuosanai <288084358+rokuosanai@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:53:39 +0000 Subject: [PATCH 4/4] fix: migrate trusted image host defaults --- pkg/config/config_test.go | 16 +++++++++++++--- pkg/config/type.go | 5 ++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7bc4059..5da370f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -48,7 +48,7 @@ func TestConfigValidate_RejectsInvalidTrustedImageHosts(t *testing.T) { } } -func TestReload_UsesDefaultTrustedImageHostsWhenOmitted(t *testing.T) { +func TestReload_MigratesOmittedTrustedImageHostsToDefaults(t *testing.T) { tempDir := t.TempDir() originalWd, err := os.Getwd() if err != nil { @@ -76,12 +76,22 @@ func TestReload_UsesDefaultTrustedImageHostsWhenOmitted(t *testing.T) { if err != nil { t.Fatalf("reload: %v", err) } - if reloaded.Output.Images.TrustedHosts != nil { - t.Fatalf("trusted hosts = %#v, want omitted value", reloaded.Output.Images.TrustedHosts) + if reloaded.Output.Images.TrustedHosts == nil { + t.Fatal("trusted hosts were not migrated to defaults") } if got := reloaded.TrustedImageHosts(); len(got) != len(defaultTrustedImageHosts) { t.Fatalf("trusted hosts = %#v, want defaults", got) } + if err := Write(reloaded); err != nil { + t.Fatalf("write migrated config: %v", err) + } + data, err := os.ReadFile(GetConfigPath()) + if err != nil { + t.Fatalf("read migrated config: %v", err) + } + if !strings.Contains(string(data), "trusted_hosts:") || !strings.Contains(string(data), "- github.com") { + t.Fatalf("expected migrated trusted hosts in config, got:\n%s", string(data)) + } } func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) { diff --git a/pkg/config/type.go b/pkg/config/type.go index 416624f..5376243 100644 --- a/pkg/config/type.go +++ b/pkg/config/type.go @@ -119,7 +119,7 @@ func NewOutputImagesConfig() *OutputImagesConfig { Directory: "static/images/%Y-%m-%d_%H%M%S", Filename: "[:id].png", BaseURL: &url, - TrustedHosts: defaultTrustedImageHosts, + TrustedHosts: append([]string(nil), defaultTrustedImageHosts...), } } @@ -170,6 +170,9 @@ func (c *Config) normalize() { if c.Output.Images == nil { c.Output.Images = &OutputImagesConfig{} } + if c.Output.Images.TrustedHosts == nil { + c.Output.Images.TrustedHosts = append([]string(nil), defaultTrustedImageHosts...) + } if c.Hugo == nil { return