Proxify images in API responses

This commit is contained in:
Romain de Laage 2022-10-15 08:17:17 +02:00 committed by Frédéric Guillot
parent 206be5ba15
commit 3f14d08095
12 changed files with 146 additions and 19 deletions

View file

@ -14,13 +14,14 @@ import (
) )
type handler struct { type handler struct {
store *storage.Storage store *storage.Storage
pool *worker.Pool pool *worker.Pool
router *mux.Router
} }
// Serve declares API routes for the application. // Serve declares API routes for the application.
func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
handler := &handler{store, pool} handler := &handler{store, pool, router}
sr := router.PathPrefix("/v1").Subrouter() sr := router.PathPrefix("/v1").Subrouter()
middleware := newMiddleware(store) middleware := newMiddleware(store)

View file

@ -9,17 +9,21 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"miniflux.app/config"
"miniflux.app/http/request" "miniflux.app/http/request"
"miniflux.app/http/response/json" "miniflux.app/http/response/json"
"miniflux.app/model" "miniflux.app/model"
"miniflux.app/proxy"
"miniflux.app/reader/processor" "miniflux.app/reader/processor"
"miniflux.app/storage" "miniflux.app/storage"
"miniflux.app/url"
"miniflux.app/validator" "miniflux.app/validator"
) )
func getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) { func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.EntryQueryBuilder) {
entry, err := b.GetEntry() entry, err := b.GetEntry()
if err != nil { if err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
@ -31,6 +35,15 @@ func getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b *storage.Entr
return return
} }
entry.Content = proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entry.Content)
proxyImage := config.Opts.ProxyImages()
for i := range entry.Enclosures {
if strings.HasPrefix(entry.Enclosures[i].MimeType, "image/") && (proxyImage == "all" || proxyImage != "none" && !url.IsHTTPS(entry.Enclosures[i].URL)) {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
}
}
json.OK(w, r, entry) json.OK(w, r, entry)
} }
@ -42,7 +55,7 @@ func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
builder.WithFeedID(feedID) builder.WithFeedID(feedID)
builder.WithEntryID(entryID) builder.WithEntryID(entryID)
getEntryFromBuilder(w, r, builder) h.getEntryFromBuilder(w, r, builder)
} }
func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) { func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
@ -53,7 +66,7 @@ func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
builder.WithCategoryID(categoryID) builder.WithCategoryID(categoryID)
builder.WithEntryID(entryID) builder.WithEntryID(entryID)
getEntryFromBuilder(w, r, builder) h.getEntryFromBuilder(w, r, builder)
} }
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) { func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
@ -61,7 +74,7 @@ func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
builder := h.store.NewEntryQueryBuilder(request.UserID(r)) builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID) builder.WithEntryID(entryID)
getEntryFromBuilder(w, r, builder) h.getEntryFromBuilder(w, r, builder)
} }
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) { func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
@ -141,6 +154,10 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
return return
} }
for i := range entries {
entries[i].Content = proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entries[i].Content)
}
json.OK(w, r, &entriesResponse{Total: count, Entries: entries}) json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
} }

View file

@ -5,6 +5,7 @@
package config // import "miniflux.app/config" package config // import "miniflux.app/config"
import ( import (
"crypto/rand"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@ -139,10 +140,14 @@ type Options struct {
metricsAllowedNetworks []string metricsAllowedNetworks []string
watchdog bool watchdog bool
invidiousInstance string invidiousInstance string
proxyPrivateKey []byte
} }
// NewOptions returns Options with default values. // NewOptions returns Options with default values.
func NewOptions() *Options { func NewOptions() *Options {
randomKey := make([]byte, 16)
rand.Read(randomKey)
return &Options{ return &Options{
HTTPS: defaultHTTPS, HTTPS: defaultHTTPS,
logDateTime: defaultLogDateTime, logDateTime: defaultLogDateTime,
@ -199,6 +204,7 @@ func NewOptions() *Options {
metricsAllowedNetworks: []string{defaultMetricsAllowedNetworks}, metricsAllowedNetworks: []string{defaultMetricsAllowedNetworks},
watchdog: defaultWatchdog, watchdog: defaultWatchdog,
invidiousInstance: defaultInvidiousInstance, invidiousInstance: defaultInvidiousInstance,
proxyPrivateKey: randomKey,
} }
} }
@ -498,6 +504,11 @@ func (o *Options) InvidiousInstance() string {
return o.invidiousInstance return o.invidiousInstance
} }
// ProxyPrivateKey returns the private key used by the media proxy
func (o *Options) ProxyPrivateKey() []byte {
return o.proxyPrivateKey
}
// SortedOptions returns options as a list of key value pairs, sorted by keys. // SortedOptions returns options as a list of key value pairs, sorted by keys.
func (o *Options) SortedOptions(redactSecret bool) []*Option { func (o *Options) SortedOptions(redactSecret bool) []*Option {
var keyValues = map[string]interface{}{ var keyValues = map[string]interface{}{
@ -552,6 +563,7 @@ func (o *Options) SortedOptions(redactSecret bool) []*Option {
"POLLING_SCHEDULER": o.pollingScheduler, "POLLING_SCHEDULER": o.pollingScheduler,
"PROXY_IMAGES": o.proxyImages, "PROXY_IMAGES": o.proxyImages,
"PROXY_IMAGE_URL": o.proxyImageUrl, "PROXY_IMAGE_URL": o.proxyImageUrl,
"PROXY_PRIVATE_KEY": redactSecretValue(string(o.proxyPrivateKey), redactSecret),
"ROOT_URL": o.rootURL, "ROOT_URL": o.rootURL,
"RUN_MIGRATIONS": o.runMigrations, "RUN_MIGRATIONS": o.runMigrations,
"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval, "SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,

View file

@ -7,6 +7,7 @@ package config // import "miniflux.app/config"
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/rand"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -199,6 +200,10 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.watchdog = parseBool(value, defaultWatchdog) p.opts.watchdog = parseBool(value, defaultWatchdog)
case "INVIDIOUS_INSTANCE": case "INVIDIOUS_INSTANCE":
p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance) p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
case "PROXY_PRIVATE_KEY":
randomKey := make([]byte, 16)
rand.Read(randomKey)
p.opts.proxyPrivateKey = parseBytes(value, randomKey)
} }
} }
@ -279,6 +284,14 @@ func parseStringList(value string, fallback []string) []string {
return strList return strList
} }
func parseBytes(value string, fallback []byte) []byte {
if value == "" {
return fallback
}
return []byte(value)
}
func readSecretFile(filename, fallback string) string { func readSecretFile(filename, fallback string) string {
data, err := os.ReadFile(filename) data, err := os.ReadFile(filename)
if err != nil { if err != nil {

View file

@ -15,6 +15,7 @@ import (
"miniflux.app/integration" "miniflux.app/integration"
"miniflux.app/logger" "miniflux.app/logger"
"miniflux.app/model" "miniflux.app/model"
"miniflux.app/proxy"
"miniflux.app/storage" "miniflux.app/storage"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -22,7 +23,7 @@ import (
// Serve handles Fever API calls. // Serve handles Fever API calls.
func Serve(router *mux.Router, store *storage.Storage) { func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store} handler := &handler{store, router}
sr := router.PathPrefix("/fever").Subrouter() sr := router.PathPrefix("/fever").Subrouter()
sr.Use(newMiddleware(store).serve) sr.Use(newMiddleware(store).serve)
@ -30,7 +31,8 @@ func Serve(router *mux.Router, store *storage.Storage) {
} }
type handler struct { type handler struct {
store *storage.Storage store *storage.Storage
router *mux.Router
} }
func (h *handler) serve(w http.ResponseWriter, r *http.Request) { func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
@ -308,7 +310,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
FeedID: entry.FeedID, FeedID: entry.FeedID,
Title: entry.Title, Title: entry.Title,
Author: entry.Author, Author: entry.Author,
HTML: entry.Content, HTML: proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entry.Content),
URL: entry.URL, URL: entry.URL,
IsSaved: isSaved, IsSaved: isSaved,
IsRead: isRead, IsRead: isRead,

View file

@ -21,9 +21,11 @@ import (
"miniflux.app/integration" "miniflux.app/integration"
"miniflux.app/logger" "miniflux.app/logger"
"miniflux.app/model" "miniflux.app/model"
"miniflux.app/proxy"
mff "miniflux.app/reader/handler" mff "miniflux.app/reader/handler"
mfs "miniflux.app/reader/subscription" mfs "miniflux.app/reader/subscription"
"miniflux.app/storage" "miniflux.app/storage"
"miniflux.app/url"
"miniflux.app/validator" "miniflux.app/validator"
) )
@ -839,6 +841,15 @@ func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) {
categories = append(categories, userStarred) categories = append(categories, userStarred)
} }
entry.Content = proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entry.Content)
proxyImage := config.Opts.ProxyImages()
for i := range entry.Enclosures {
if strings.HasPrefix(entry.Enclosures[i].MimeType, "image/") && (proxyImage == "all" || proxyImage != "none" && !url.IsHTTPS(entry.Enclosures[i].URL)) {
entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
}
}
contentItems[i] = contentItem{ contentItems[i] = contentItem{
ID: fmt.Sprintf(EntryIDLong, entry.ID), ID: fmt.Sprintf(EntryIDLong, entry.ID),
Title: entry.Title, Title: entry.Title,

View file

@ -426,6 +426,11 @@ Enabled by default\&.
Set a custom invidious instance to use\&. Set a custom invidious instance to use\&.
.br .br
Default is yewtu.be\&. Default is yewtu.be\&.
.TP
.B PROXY_PRIVATE_KEY
Set a custom custom private key used to sign proxified media url\&.
.br
Default is randomly generated at startup\&.
.SH AUTHORS .SH AUTHORS
.P .P

View file

@ -15,8 +15,22 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
type urlProxyRewriter func(router *mux.Router, url string) string
// ImageProxyRewriter replaces image URLs with internal proxy URLs. // ImageProxyRewriter replaces image URLs with internal proxy URLs.
func ImageProxyRewriter(router *mux.Router, data string) string { func ImageProxyRewriter(router *mux.Router, data string) string {
return genericImageProxyRewriter(router, ProxifyURL, data)
}
// AbsoluteImageProxyRewriter do the same as ImageProxyRewriter except it uses absolute URLs.
func AbsoluteImageProxyRewriter(router *mux.Router, host, data string) string {
proxifyFunction := func(router *mux.Router, url string) string {
return AbsoluteProxifyURL(router, host, url)
}
return genericImageProxyRewriter(router, proxifyFunction, data)
}
func genericImageProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
proxyImages := config.Opts.ProxyImages() proxyImages := config.Opts.ProxyImages()
if proxyImages == "none" { if proxyImages == "none" {
return data return data
@ -30,18 +44,18 @@ func ImageProxyRewriter(router *mux.Router, data string) string {
doc.Find("img").Each(func(i int, img *goquery.Selection) { doc.Find("img").Each(func(i int, img *goquery.Selection) {
if srcAttrValue, ok := img.Attr("src"); ok { if srcAttrValue, ok := img.Attr("src"); ok {
if !isDataURL(srcAttrValue) && (proxyImages == "all" || !url.IsHTTPS(srcAttrValue)) { if !isDataURL(srcAttrValue) && (proxyImages == "all" || !url.IsHTTPS(srcAttrValue)) {
img.SetAttr("src", ProxifyURL(router, srcAttrValue)) img.SetAttr("src", proxifyFunction(router, srcAttrValue))
} }
} }
if srcsetAttrValue, ok := img.Attr("srcset"); ok { if srcsetAttrValue, ok := img.Attr("srcset"); ok {
proxifySourceSet(img, router, proxyImages, srcsetAttrValue) proxifySourceSet(img, router, proxifyFunction, proxyImages, srcsetAttrValue)
} }
}) })
doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) { doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok { if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
proxifySourceSet(sourceElement, router, proxyImages, srcsetAttrValue) proxifySourceSet(sourceElement, router, proxifyFunction, proxyImages, srcsetAttrValue)
} }
}) })
@ -53,12 +67,12 @@ func ImageProxyRewriter(router *mux.Router, data string) string {
return output return output
} }
func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxyImages, srcsetAttrValue string) { func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFunction urlProxyRewriter, proxyImages, srcsetAttrValue string) {
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue) imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
for _, imageCandidate := range imageCandidates { for _, imageCandidate := range imageCandidates {
if !isDataURL(imageCandidate.ImageURL) && (proxyImages == "all" || !url.IsHTTPS(imageCandidate.ImageURL)) { if !isDataURL(imageCandidate.ImageURL) && (proxyImages == "all" || !url.IsHTTPS(imageCandidate.ImageURL)) {
imageCandidate.ImageURL = ProxifyURL(router, imageCandidate.ImageURL) imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
} }
} }

View file

@ -5,6 +5,8 @@
package proxy // import "miniflux.app/proxy" package proxy // import "miniflux.app/proxy"
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"net/url" "net/url"
"path" "path"
@ -16,13 +18,44 @@ import (
"miniflux.app/config" "miniflux.app/config"
) )
// ProxifyURL generates an URL for a proxified resource. // ProxifyURL generates a relative URL for a proxified resource.
func ProxifyURL(router *mux.Router, link string) string { func ProxifyURL(router *mux.Router, link string) string {
if link != "" { if link != "" {
proxyImageUrl := config.Opts.ProxyImageUrl() proxyImageUrl := config.Opts.ProxyImageUrl()
if proxyImageUrl == "" { if proxyImageUrl == "" {
return route.Path(router, "proxy", "encodedURL", base64.URLEncoding.EncodeToString([]byte(link))) mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
return route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
}
proxyUrl, err := url.Parse(proxyImageUrl)
if err != nil {
return ""
}
proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
return proxyUrl.String()
}
return ""
}
// AbsoluteProxifyURL generates an absolute URL for a proxified resource.
func AbsoluteProxifyURL(router *mux.Router, host, link string) string {
if link != "" {
proxyImageUrl := config.Opts.ProxyImageUrl()
if proxyImageUrl == "" {
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write([]byte(link))
digest := mac.Sum(nil)
path := route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
if config.Opts.HTTPS {
return "https://" + host + path
} else {
return "http://" + host + path
}
} }
proxyUrl, err := url.Parse(proxyImageUrl) proxyUrl, err := url.Parse(proxyImageUrl)

View file

@ -143,7 +143,8 @@ func (m *middleware) isPublicRoute(r *http.Request) bool {
"robots", "robots",
"sharedEntry", "sharedEntry",
"healthcheck", "healthcheck",
"offline": "offline",
"proxy":
return true return true
default: default:
return false return false

View file

@ -5,6 +5,8 @@
package ui // import "miniflux.app/ui" package ui // import "miniflux.app/ui"
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"errors" "errors"
"net/http" "net/http"
@ -25,18 +27,34 @@ func (h *handler) imageProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
encodedDigest := request.RouteStringParam(r, "encodedDigest")
encodedURL := request.RouteStringParam(r, "encodedURL") encodedURL := request.RouteStringParam(r, "encodedURL")
if encodedURL == "" { if encodedURL == "" {
html.BadRequest(w, r, errors.New("No URL provided")) html.BadRequest(w, r, errors.New("No URL provided"))
return return
} }
decodedDigest, err := base64.URLEncoding.DecodeString(encodedDigest)
if err != nil {
html.BadRequest(w, r, errors.New("Unable to decode this Digest"))
return
}
decodedURL, err := base64.URLEncoding.DecodeString(encodedURL) decodedURL, err := base64.URLEncoding.DecodeString(encodedURL)
if err != nil { if err != nil {
html.BadRequest(w, r, errors.New("Unable to decode this URL")) html.BadRequest(w, r, errors.New("Unable to decode this URL"))
return return
} }
mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
mac.Write(decodedURL)
expectedMAC := mac.Sum(nil)
if !hmac.Equal(decodedDigest, expectedMAC) {
html.Forbidden(w, r)
return
}
imageURL := string(decodedURL) imageURL := string(decodedURL)
logger.Debug(`[Proxy] Fetching %q`, imageURL) logger.Debug(`[Proxy] Fetching %q`, imageURL)

View file

@ -94,7 +94,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost) uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost) uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)
uiRouter.HandleFunc("/entry/download/{entryID}", handler.fetchContent).Name("fetchContent").Methods(http.MethodPost) uiRouter.HandleFunc("/entry/download/{entryID}", handler.fetchContent).Name("fetchContent").Methods(http.MethodPost)
uiRouter.HandleFunc("/proxy/{encodedURL}", handler.imageProxy).Name("proxy").Methods(http.MethodGet) uiRouter.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", handler.imageProxy).Name("proxy").Methods(http.MethodGet)
uiRouter.HandleFunc("/entry/bookmark/{entryID}", handler.toggleBookmark).Name("toggleBookmark").Methods(http.MethodPost) uiRouter.HandleFunc("/entry/bookmark/{entryID}", handler.toggleBookmark).Name("toggleBookmark").Methods(http.MethodPost)
// Share pages. // Share pages.