feat: add ability to search and select icons from selfh.st (#1807)

This commit is contained in:
Elias Schneider
2026-10-04 20:40:41 +02:00
committed by GitHub
parent e7d77d8a9d
commit 1be9ba9991
30 changed files with 1506 additions and 247 deletions
@@ -276,6 +276,14 @@ func LogoTooLarge(maxSize string) *Error {
WithDetail("max_size", maxSize)
}
func LogoPresetsDisabled() *Error {
return New(CodeLogoPresetsDisabled, http.StatusForbidden, "Logo presets are disabled")
}
func LogoPresetsUnavailable(cause error) *Error {
return Wrap(cause, CodeLogoPresetsUnavailable, http.StatusBadGateway, "Logo presets could not be loaded")
}
func InvalidLogoURL(cause error) *Error {
return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{
Field: "logoUrl",
+2
View File
@@ -67,6 +67,8 @@ const (
CodeLogoDownloadFailed Code = "logo_download_failed"
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
CodeLogoTooLarge Code = "logo_too_large"
CodeLogoPresetsDisabled Code = "logo_presets_disabled"
CodeLogoPresetsUnavailable Code = "logo_presets_unavailable"
CodeOidcPARRequired Code = "oidc_par_required"
)
@@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
svc.environmentModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().Add())
svc.logoPresetModule.RegisterRoutes(apiGroup, authMiddleware.Add())
svc.scimSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add())
svc.userSignUpModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
@@ -19,6 +19,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/geolite"
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
"github.com/pocket-id/pocket-id/backend/internal/ldapsync"
"github.com/pocket-id/pocket-id/backend/internal/logopreset"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
"github.com/pocket-id/pocket-id/backend/internal/scimsync"
@@ -55,6 +56,7 @@ type services struct {
emailVerificationModule *emailverification.Module
apiModule *api.Module
environmentModule *environment.Module
logoPresetModule *logopreset.Module
actors francishost.Host
}
@@ -258,6 +260,16 @@ func initServices(
SQLiteOnNetworkedFilesystem: sqliteOnNetworkedFilesystem,
})
// An empty base URL keeps the icon library disabled
var iconLibraryURL string
if common.EnvConfig.IconLibraryEnabled() {
iconLibraryURL = common.EnvConfig.IconLibraryURL
}
svc.logoPresetModule = logopreset.New(logopreset.Dependencies{
HTTPClient: httpClient,
BaseURL: iconLibraryURL,
})
return svc, nil
}
+59
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/url"
"os"
"path"
"reflect"
"strconv"
"strings"
@@ -44,6 +45,11 @@ const (
defaultFsUploadPath string = "data/uploads"
AppUrl string = "http://localhost:1411"
// IconLibraryDisabled is the ICON_LIBRARY_URL value that turns off the icon library for OIDC client logos
IconLibraryDisabled string = "disabled"
// DefaultIconLibraryURL serves the selfh.st icon collection through jsDelivr
DefaultIconLibraryURL string = "https://cdn.jsdelivr.net/gh/selfhst/icons@main"
// FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process
FrancisHostEmbedded string = "embedded"
// francisHostPSKMinLength is the min length for the host bootstrap pre-shared key for Francis
@@ -98,6 +104,11 @@ type EnvConfigSchema struct {
// CloudflareLocationHeaders trusts location headers supplied by a Cloudflare proxy instead of using GeoLite
CloudflareLocationHeaders bool `env:"CLOUDFLARE_LOCATION_HEADERS"`
// IconLibraryURL is the base URL of the icon collection admins can pick OIDC client logos from
// It must serve index.json and the svg and png directories in the same layout as the selfh.st/icons repository
// Setting it to "disabled" turns the icon library off, so Pocket ID never contacts it
IconLibraryURL string `env:"ICON_LIBRARY_URL" options:"trimTrailingSlash"`
ActorsPort string `env:"ACTORS_PORT"`
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
@@ -166,6 +177,7 @@ func defaultConfig() EnvConfigSchema {
FrancisHost: FrancisHostEmbedded,
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
IconLibraryURL: DefaultIconLibraryURL,
}
}
@@ -249,6 +261,11 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return err
}
err = validateIconLibraryURL(config)
if err != nil {
return err
}
err = validateLocalIPv6Ranges(config.LocalIPv6Ranges)
if err != nil {
return err
@@ -391,6 +408,48 @@ func validateURLWithoutPath(rawURL, envName string) error {
return nil
}
// validateIconLibraryURL checks that ICON_LIBRARY_URL is either "disabled" or an absolute HTTP(S) URL
func validateIconLibraryURL(config *EnvConfigSchema) error {
switch {
case config.IconLibraryURL == "":
config.IconLibraryURL = DefaultIconLibraryURL
return nil
case strings.EqualFold(config.IconLibraryURL, IconLibraryDisabled):
config.IconLibraryURL = IconLibraryDisabled
return nil
}
parsedURL, err := url.Parse(config.IconLibraryURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
return errors.New("ICON_LIBRARY_URL must be an HTTP or HTTPS URL, or 'disabled'")
}
return nil
}
// IconLibraryEnabled reports whether admins can pick OIDC client logos from the icon library
func (c *EnvConfigSchema) IconLibraryEnabled() bool {
return c.IconLibraryURL != IconLibraryDisabled
}
// IsIconLibraryURL reports whether the URL points to a file inside the configured icon library
// Dot segments are rejected so a URL can't climb out of the library's path on the same host
func (c *EnvConfigSchema) IsIconLibraryURL(u *url.URL) bool {
if !c.IconLibraryEnabled() {
return false
}
base, err := url.Parse(c.IconLibraryURL)
if err != nil {
return false
}
return u.Scheme == base.Scheme &&
strings.EqualFold(u.Host, base.Host) &&
u.Path == path.Clean(u.Path) &&
strings.HasPrefix(u.Path, base.Path+"/")
}
func validateFileBackend(config *EnvConfigSchema) error {
switch config.FileBackend {
case "s3", "database":
@@ -1,6 +1,7 @@
package common
import (
"net/url"
"os"
"testing"
@@ -657,3 +658,79 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
assert.Equal(t, keyContent, EnvConfig.TLSKey)
})
}
func TestIconLibraryConfig(t *testing.T) {
originalConfig := EnvConfig
t.Cleanup(func() {
EnvConfig = originalConfig
})
tests := []struct {
name string
value *string
wantURL string
wantEnabled bool
wantErr string
}{
{name: "defaults to the selfh.st collection", wantURL: DefaultIconLibraryURL, wantEnabled: true},
{name: "empty value falls back to the default", value: new(""), wantURL: DefaultIconLibraryURL, wantEnabled: true},
{name: "custom URL without the trailing slash", value: new("http://192.168.1.10:4050/icons/"), wantURL: "http://192.168.1.10:4050/icons", wantEnabled: true},
{name: "disabled is case-insensitive", value: new("Disabled"), wantURL: IconLibraryDisabled, wantEnabled: false},
{name: "rejects other schemes", value: new("ftp://mirror.example.com/icons"), wantErr: "ICON_LIBRARY_URL must be an HTTP or HTTPS URL"},
{name: "rejects values without a host", value: new("icons"), wantErr: "ICON_LIBRARY_URL must be an HTTP or HTTPS URL"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
EnvConfig = defaultConfig()
if tt.value != nil {
t.Setenv("ICON_LIBRARY_URL", *tt.value)
}
err := parseAndValidateEnvConfig(t)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantURL, EnvConfig.IconLibraryURL)
assert.Equal(t, tt.wantEnabled, EnvConfig.IconLibraryEnabled())
})
}
}
func TestIsIconLibraryURL(t *testing.T) {
config := EnvConfigSchema{IconLibraryURL: "http://mirror.lan:4050/icons"}
tests := []struct {
name string
url string
want bool
}{
{name: "file inside the library", url: "http://mirror.lan:4050/icons/svg/nextcloud.svg", want: true},
{name: "host is matched case-insensitively", url: "http://MIRROR.lan:4050/icons/svg/nextcloud.svg", want: true},
{name: "path outside the library", url: "http://mirror.lan:4050/admin/logo.svg", want: false},
{name: "path sharing the library prefix", url: "http://mirror.lan:4050/icons-private/logo.svg", want: false},
{name: "dot segments climbing out of the library", url: "http://mirror.lan:4050/icons/../admin/logo.svg", want: false},
{name: "encoded dot segments", url: "http://mirror.lan:4050/icons/%2e%2e/admin/logo.svg", want: false},
{name: "host that starts with the library host", url: "http://mirror.lan.evil.com:4050/icons/svg/nextcloud.svg", want: false},
{name: "different port", url: "http://mirror.lan:8080/icons/svg/nextcloud.svg", want: false},
{name: "different scheme", url: "https://mirror.lan:4050/icons/svg/nextcloud.svg", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.url)
require.NoError(t, err)
assert.Equal(t, tt.want, config.IsIconLibraryURL(u))
})
}
t.Run("nothing is inside a disabled library", func(t *testing.T) {
disabled := EnvConfigSchema{IconLibraryURL: IconLibraryDisabled}
u, err := url.Parse("http://disabled/svg/nextcloud.svg")
require.NoError(t, err)
assert.False(t, disabled.IsIconLibraryURL(u))
})
}
@@ -79,10 +79,28 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) error {
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
})
// Manually add iconLibrary, derived from ICON_LIBRARY_URL, so the frontend can hide the icon picker and only credits selfh.st when its collection is used
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "iconLibrary",
Value: iconLibraryStatus(),
})
c.JSON(http.StatusOK, configVariablesDto)
return nil
}
// iconLibraryStatus reports whether the icon library is disabled, uses the default selfh.st collection, or points to a custom source
func iconLibraryStatus() string {
switch common.EnvConfig.IconLibraryURL {
case common.IconLibraryDisabled:
return "disabled"
case common.DefaultIconLibraryURL:
return "default"
default:
return "custom"
}
}
// listAllAppConfigHandler godoc
// @Summary List all application configurations
// @Description Get all application configurations including private ones
+8
View File
@@ -0,0 +1,8 @@
package logopreset
type logoPresetDto struct {
Name string `json:"name"`
Reference string `json:"reference"`
LogoURL string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
}
+36
View File
@@ -0,0 +1,36 @@
package logopreset
import (
"net/http"
"github.com/gin-gonic/gin"
_ "github.com/pocket-id/pocket-id/backend/internal/dto"
)
type handler struct {
service *Service
}
func newHandler(service *Service) *handler {
return &handler{service: service}
}
// search godoc
// @Summary Search logo presets
// @Description Search the icon library for logos that can be used for OIDC clients
// @Tags OIDC
// @Produce json
// @Param search query string false "Search term matched against the icon name"
// @Success 200 {array} logoPresetDto
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/oidc/logo-presets [get]
func (h *handler) search(c *gin.Context) error {
presets, err := h.service.Search(c.Request.Context(), c.Query("search"))
if err != nil {
return err
}
c.JSON(http.StatusOK, presets)
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package logopreset
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
)
type Dependencies struct {
HTTPClient *http.Client
// BaseURL is the root of the icon library, or empty when the icon library is disabled
BaseURL string
}
// Module lets admins search an icon collection in the selfh.st/icons layout for OIDC client logos
// The chosen icon is downloaded through the regular logo URL flow of the OIDC client update
type Module struct {
service *Service
handler *handler
}
func New(deps Dependencies) *Module {
service := newService(deps)
return &Module{
service: service,
handler: newHandler(service),
}
}
// RegisterRoutes mounts the logo preset endpoints
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth gin.HandlerFunc) {
apiGroup.GET("/oidc/logo-presets", auth, httpserver.Handle(m.handler.search))
}
+224
View File
@@ -0,0 +1,224 @@
package logopreset
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"slices"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
indexTTL = 24 * time.Hour
fetchTimeout = 10 * time.Second
maxIndexSize = 5 << 20
maxResults = 30
)
// referencePattern matches the slugs selfh.st uses as file names, so a reference can never escape its directory in an icon URL
var referencePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
// nonAlphanumeric is stripped from names and queries so "home assistant" matches "Home-Assistant"
var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)
// indexEntry is one icon in selfh.st's index.json, whose availability flags are the strings "Yes" or "No"
type indexEntry struct {
Name string `json:"Name"`
Reference string `json:"Reference"`
SVG string `json:"SVG"`
Light string `json:"Light"`
Tags string `json:"Tags"`
}
// preset is an index entry with its search keys normalized once when the index is loaded
type preset struct {
indexEntry
searchName string
searchReference string
searchTags string
}
type Service struct {
httpClient *http.Client
baseURL string
cache *utils.Cache[[]preset]
}
func newService(deps Dependencies) *Service {
return &Service{
httpClient: deps.HTTPClient,
baseURL: deps.BaseURL,
cache: utils.New[[]preset](indexTTL),
}
}
// Search returns the icons that best match the query, ranked from exact to loose matches
func (s *Service) Search(ctx context.Context, query string) ([]logoPresetDto, error) {
// Operators can turn the icon library off so Pocket ID never contacts it
if s.baseURL == "" {
return nil, apperror.LogoPresetsDisabled()
}
presets, err := s.getIndex(ctx)
if err != nil {
return nil, apperror.LogoPresetsUnavailable(err)
}
matches := search(presets, query, maxResults)
result := make([]logoPresetDto, len(matches))
for i, p := range matches {
result[i] = s.toDto(p)
}
return result, nil
}
func (s *Service) getIndex(ctx context.Context) ([]preset, error) {
presets, err := s.cache.GetOrFetch(ctx, s.fetchIndex)
// A stale index is still good enough to search while the CDN is unreachable
if staleErr, ok := errors.AsType[*utils.ErrStale](err); ok {
slog.WarnContext(ctx, "Failed to refresh logo preset index, using stale cache", slog.Any("error", staleErr.Err))
return presets, nil
}
return presets, err
}
func (s *Service) fetchIndex(ctx context.Context) ([]preset, error) {
reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
// Download the index that lists every icon in the collection
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, s.baseURL+"/index.json", nil)
if err != nil {
return nil, fmt.Errorf("create icon index request: %w", err)
}
req.Header.Set("User-Agent", "pocket-id/logo-presets")
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch icon index: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("icon index returned status %d", resp.StatusCode)
}
var entries []indexEntry
err = json.NewDecoder(utils.NewLimitReader(resp.Body, maxIndexSize)).Decode(&entries)
if err != nil {
return nil, fmt.Errorf("decode icon index: %w", err)
}
// Skip entries whose reference can't safely be used as a file name in an icon URL
presets := make([]preset, 0, len(entries))
for _, e := range entries {
if !referencePattern.MatchString(e.Reference) {
continue
}
presets = append(presets, preset{
indexEntry: e,
searchName: normalize(e.Name),
searchReference: normalize(e.Reference),
searchTags: strings.ToLower(e.Tags),
})
}
return presets, nil
}
// search returns up to limit presets matching the query, ordered by match quality and then by name
func search(presets []preset, query string, limit int) []preset {
normalizedQuery := normalize(query)
tagQuery := strings.ToLower(strings.TrimSpace(query))
type match struct {
preset preset
rank int
}
// Rank every preset that matches the query at all
matches := make([]match, 0, limit)
for _, p := range presets {
rank, ok := matchRank(p, normalizedQuery, tagQuery)
if ok {
matches = append(matches, match{preset: p, rank: rank})
}
}
// Lower ranks are better matches, and ties are broken alphabetically so results are stable
slices.SortFunc(matches, func(a, b match) int {
return cmp.Or(
cmp.Compare(a.rank, b.rank),
cmp.Compare(a.preset.searchName, b.preset.searchName),
)
})
result := make([]preset, 0, min(limit, len(matches)))
for _, m := range matches[:min(limit, len(matches))] {
result = append(result, m.preset)
}
return result
}
// matchRank reports how well a preset matches the query, where 0 is an exact match
func matchRank(p preset, normalizedQuery, tagQuery string) (int, bool) {
switch {
case normalizedQuery == "":
return 0, true
case p.searchName == normalizedQuery || p.searchReference == normalizedQuery:
return 0, true
case strings.HasPrefix(p.searchName, normalizedQuery) || strings.HasPrefix(p.searchReference, normalizedQuery):
return 1, true
case strings.Contains(p.searchName, normalizedQuery) || strings.Contains(p.searchReference, normalizedQuery):
return 2, true
case tagQuery != "" && strings.Contains(p.searchTags, tagQuery):
return 3, true
default:
return 0, false
}
}
func (s *Service) toDto(p preset) logoPresetDto {
format := "png"
if p.SVG == "Yes" {
format = "svg"
}
dto := logoPresetDto{
Name: p.Name,
Reference: p.Reference,
LogoURL: s.iconURL(format, p.Reference),
}
// The white variant keeps monochrome logos readable on dark backgrounds
if p.Light == "Yes" {
darkLogoURL := s.iconURL(format, p.Reference+"-light")
dto.DarkLogoURL = &darkLogoURL
}
return dto
}
func (s *Service) iconURL(format, name string) string {
return s.baseURL + "/" + format + "/" + name + "." + format
}
func normalize(s string) string {
return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "")
}
+180
View File
@@ -0,0 +1,180 @@
package logopreset
import (
"errors"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
const testBaseURL = "https://icons.example.com"
const testIndex = `[
{"Name": "Nextcloud", "Reference": "nextcloud", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Cloud Storage"},
{"Name": "Nextcloud Talk", "Reference": "nextcloud-talk", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""},
{"Name": "Ghost", "Reference": "ghost", "SVG": "No", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Blogging"},
{"Name": "Home Assistant", "Reference": "home-assistant", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": ""},
{"Name": "OwnCloud", "Reference": "owncloud", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": "Cloud Storage"},
{"Name": "Bad", "Reference": "../bad", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""}
]`
// countingRoundTripper counts requests before delegating to the mock, so tests can assert when the index is fetched
type countingRoundTripper struct {
next http.RoundTripper
calls atomic.Int32
}
func (c *countingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
c.calls.Add(1)
return c.next.RoundTrip(req)
}
func newTestService(t *testing.T, mock *testutils.MockRoundTripper) (*Service, *countingRoundTripper) {
t.Helper()
transport := &countingRoundTripper{next: mock}
return newService(Dependencies{HTTPClient: &http.Client{Transport: transport}, BaseURL: testBaseURL}), transport
}
func indexMock() *testutils.MockRoundTripper {
return &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusOK, testIndex), //nolint:bodyclose // mock response, no real body
},
}
}
func references(presets []logoPresetDto) []string {
refs := make([]string, len(presets))
for i, p := range presets {
refs[i] = p.Reference
}
return refs
}
func TestSearch_Disabled(t *testing.T) {
svc, transport := newTestService(t, indexMock())
svc.baseURL = ""
_, err := svc.Search(t.Context(), "nextcloud")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsDisabled))
assert.Zero(t, transport.calls.Load(), "a disabled icon library must not be contacted")
}
func TestSearch_Ranking(t *testing.T) {
svc, _ := newTestService(t, indexMock())
tests := []struct {
name string
query string
want []string
}{
{name: "exact match ranks before prefix match", query: "nextcloud", want: []string{"nextcloud", "nextcloud-talk"}},
{name: "substring matches come after prefix matches", query: "cloud", want: []string{"nextcloud", "nextcloud-talk", "owncloud"}},
{name: "separators and case are ignored", query: "HOME assistant", want: []string{"home-assistant"}},
{name: "tags match last", query: "blogging", want: []string{"ghost"}},
{name: "empty query lists everything alphabetically", query: "", want: []string{"ghost", "home-assistant", "nextcloud", "nextcloud-talk", "owncloud"}},
{name: "no match", query: "jellyfin", want: []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
presets, err := svc.Search(t.Context(), tt.query)
require.NoError(t, err)
assert.Equal(t, tt.want, references(presets))
})
}
}
func TestSearch_LimitsResults(t *testing.T) {
presets := make([]preset, maxResults+10)
for i := range presets {
presets[i] = preset{searchName: "app", searchReference: "app"}
}
assert.Len(t, search(presets, "app", maxResults), maxResults)
}
func TestSearch_BuildsIconURLs(t *testing.T) {
svc, _ := newTestService(t, indexMock())
presets, err := svc.Search(t.Context(), "")
require.NoError(t, err)
byReference := make(map[string]logoPresetDto, len(presets))
for _, p := range presets {
byReference[p.Reference] = p
}
// SVG icon with a white variant for dark mode
nextcloud := byReference["nextcloud"]
assert.Equal(t, testBaseURL+"/svg/nextcloud.svg", nextcloud.LogoURL)
require.NotNil(t, nextcloud.DarkLogoURL)
assert.Equal(t, testBaseURL+"/svg/nextcloud-light.svg", *nextcloud.DarkLogoURL)
// SVG icon without a white variant leaves the dark logo empty
talk := byReference["nextcloud-talk"]
assert.Equal(t, testBaseURL+"/svg/nextcloud-talk.svg", talk.LogoURL)
assert.Nil(t, talk.DarkLogoURL)
// PNG-only icon falls back to PNG for both variants
ghost := byReference["ghost"]
assert.Equal(t, testBaseURL+"/png/ghost.png", ghost.LogoURL)
require.NotNil(t, ghost.DarkLogoURL)
assert.Equal(t, testBaseURL+"/png/ghost-light.png", *ghost.DarkLogoURL)
// References that aren't plain slugs are dropped
assert.NotContains(t, byReference, "../bad")
}
func TestSearch_CachesIndex(t *testing.T) {
svc, transport := newTestService(t, indexMock())
for range 3 {
_, err := svc.Search(t.Context(), "nextcloud")
require.NoError(t, err)
}
assert.Equal(t, int32(1), transport.calls.Load())
}
func TestSearch_UsesStaleIndexWhenRefreshFails(t *testing.T) {
mock := indexMock()
svc, _ := newTestService(t, mock)
// Expire the cache immediately so the second search has to refetch
svc.cache = utils.New[[]preset](time.Nanosecond)
_, err := svc.Search(t.Context(), "nextcloud")
require.NoError(t, err)
mock.Err = errors.New("CDN unreachable")
presets, err := svc.Search(t.Context(), "nextcloud")
require.NoError(t, err)
assert.Equal(t, []string{"nextcloud", "nextcloud-talk"}, references(presets))
}
func TestSearch_IndexUnavailable(t *testing.T) {
mock := &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusInternalServerError, ""), //nolint:bodyclose // mock response, no real body
},
}
svc, _ := newTestService(t, mock)
_, err := svc.Search(t.Context(), "nextcloud")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsUnavailable))
}
+24 -17
View File
@@ -21,6 +21,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/backchannellogout"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -522,15 +523,14 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
return nil, 0, "", err
}
// Each variant falls back to the other one, so a client with a single logo shows it in both themes
var suffix string
var ext string
switch {
case !light && client.DarkImageType != nil:
// Dark logo if requested and exists
case client.HasDarkLogo() && (!light || !client.HasLogo()):
suffix = "-dark"
ext = *client.DarkImageType
case client.ImageType != nil:
// Light logo if requested or no dark logo is available
case client.HasLogo():
ext = *client.ImageType
default:
return nil, 0, "", apperror.ImageNotFound()
@@ -943,6 +943,23 @@ func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *ht
return client
}
// checkLogoURLAllowed prevents SSRF by allowing only URLs that resolve to public IPs
// URLs inside the icon library are exempt because the operator configured it, which lets a self-hosted mirror live on the local network
func checkLogoURLAllowed(ctx context.Context, u *url.URL) error {
if common.EnvConfig.IsIconLibraryURL(u) {
return nil
}
private, err := utils.IsURLPrivate(ctx, u)
if err != nil {
return apperror.LogoDownloadFailed(err)
} else if private {
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
}
return nil
}
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error {
u, err := url.Parse(raw)
if err != nil {
@@ -955,12 +972,9 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
defer cancel()
// Prevents SSRF by allowing only public IPs
ok, err := utils.IsURLPrivate(ctx, u)
err = checkLogoURLAllowed(ctx, u)
if err != nil {
return apperror.LogoDownloadFailed(err)
} else if ok {
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
return err
}
// We need to check this on redirects too
@@ -969,14 +983,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects"))
}
ok, err := utils.IsURLPrivate(r.Context(), r.URL)
if err != nil {
return err
} else if ok {
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
}
return nil
return checkLogoURLAllowed(r.Context(), r.URL)
})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -413,6 +414,41 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
})
t.Run("Allows private hosts inside the icon library only", func(t *testing.T) {
const iconLibraryURL = "http://127.0.0.1:4050/icons"
originalIconLibraryURL := common.EnvConfig.IconLibraryURL
common.EnvConfig.IconLibraryURL = iconLibraryURL
t.Cleanup(func() {
common.EnvConfig.IconLibraryURL = originalIconLibraryURL
})
//nolint:bodyclose
svgResponse := testutils.NewMockResponse(http.StatusOK, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
svgResponse.Header.Set("Content-Type", "image/svg+xml")
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
iconLibraryURL + "/svg/nextcloud.svg": svgResponse,
},
},
},
}
// The operator configured the library, so its loopback address is trusted
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, iconLibraryURL+"/svg/nextcloud.svg", true)
require.NoError(t, err)
require.True(t, fileExists(t, "oidc-client-images/"+client.ID+".svg"))
// Other paths on the same private host are still blocked
err = s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "http://127.0.0.1:4050/admin/logo.svg", true)
require.Error(t, err)
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
})
t.Run("Returns error for non-200 status code", func(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
@@ -1074,3 +1110,69 @@ func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
}
return names
}
func TestOidcService_GetClientLogo(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
dbStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
s := &OidcService{db: db, fileStorage: dbStorage}
// createClient stores the given variants with their name as content, so the test can tell which one was served
createClient := func(t *testing.T, light, dark bool) string {
t.Helper()
client := model.OidcClient{Name: "Logo Client", CallbackURLs: datatype.StringList{"https://example.com/callback"}}
if light {
client.ImageType = new("png")
}
if dark {
client.DarkImageType = new("png")
}
require.NoError(t, db.Create(&client).Error)
if light {
require.NoError(t, dbStorage.Save(t.Context(), oidcClientImagePath(client.ID, "", "png"), strings.NewReader("light")))
}
if dark {
require.NoError(t, dbStorage.Save(t.Context(), oidcClientImagePath(client.ID, "-dark", "png"), strings.NewReader("dark")))
}
return client.ID
}
tests := []struct {
name string
light bool
dark bool
wantLight string
wantDark string
}{
{name: "serves each variant when both exist", light: true, dark: true, wantLight: "light", wantDark: "dark"},
{name: "dark mode falls back to the light logo", light: true, wantLight: "light", wantDark: "light"},
{name: "light mode falls back to the dark logo", dark: true, wantLight: "dark", wantDark: "dark"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clientID := createClient(t, tt.light, tt.dark)
for requestLight, want := range map[bool]string{true: tt.wantLight, false: tt.wantDark} {
reader, _, mimeType, err := s.GetClientLogo(t.Context(), clientID, requestLight)
require.NoError(t, err)
content, err := io.ReadAll(reader)
reader.Close()
require.NoError(t, err)
assert.Equal(t, want, string(content), "light=%t", requestLight)
assert.Equal(t, "image/png", mimeType)
}
})
}
t.Run("returns not found without any logo", func(t *testing.T) {
clientID := createClient(t, false, false)
for _, requestLight := range []bool{true, false} {
_, _, _, err := s.GetClientLogo(t.Context(), clientID, requestLight)
require.True(t, apperror.IsCode(err, apperror.CodeImageNotFound), "light=%t", requestLight)
}
})
}