Skip to content

Commit

Permalink
Disallow access in publicly exposed services (#1761)
Browse files Browse the repository at this point in the history
* Add security against publicly exposed services
* Add trusted proxies setting, validate proxy chain against internet access
* Validate chain on local proxies too
* Move authentication handler to separate file
* Add startup check and log if tripwire is active

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
  • Loading branch information
kermieisinthehouse and WithoutPants authored Oct 4, 2021
1 parent dcf58b9 commit f1da6cb
Show file tree
Hide file tree
Showing 12 changed files with 344 additions and 62 deletions.
1 change: 1 addition & 0 deletions graphql/documents/data/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
username
password
maxSessionAge
trustedProxies
logFile
logOut
logLevel
Expand Down
4 changes: 4 additions & 0 deletions graphql/schema/types/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ input ConfigGeneralInput {
password: String
"""Maximum session cookie age"""
maxSessionAge: Int
"""Comma separated list of proxies to allow traffic from"""
trustedProxies: [String!]
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
Expand Down Expand Up @@ -152,6 +154,8 @@ type ConfigGeneralResult {
password: String!
"""Maximum session cookie age"""
maxSessionAge: Int!
"""Comma separated list of proxies to allow traffic from"""
trustedProxies: [String!]!
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
Expand Down
138 changes: 138 additions & 0 deletions pkg/api/authentication.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package api

import (
"net"
"net/http"
"net/url"
"strings"

"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/session"
)

const loginEndPoint = "/login"

const (
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
"More information and fixes are available at https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet"

externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " +
"This is extremely dangerous! The whole world can see your your stash page and browse your files! " +
"Stash is not answering any other requests to protect your privacy. " +
"Please read the log entry or visit https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet"
)

func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == "/css"
}

func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := config.GetInstance()

if !checkSecurityTripwireActivated(c, w) {
return
}

userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil {
if err != session.ErrUnauthorized {
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(err.Error()))
if err != nil {
logger.Error(err)
}
return
}

// unauthorized error
w.Header().Add("WWW-Authenticate", `FormBased`)
w.WriteHeader(http.StatusUnauthorized)
return
}

if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
switch err := err.(type) {
case session.ExternalAccessError:
securityActivateTripwireAccessedFromInternetWithoutAuth(c, err, w)
return
case session.UntrustedProxyError:
logger.Warnf("Rejected request from untrusted proxy: %s", net.IP(err).String())
w.WriteHeader(http.StatusForbidden)
return
default:
logger.Errorf("Error checking external access security: %s", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
}

ctx := r.Context()

if c.HasCredentials() {
// authentication is required
if userID == "" && !allowUnauthenticated(r) {
// authentication was not received, redirect
// if graphql was requested, we just return a forbidden error
if r.URL.Path == "/graphql" {
w.Header().Add("WWW-Authenticate", `FormBased`)
w.WriteHeader(http.StatusUnauthorized)
return
}

// otherwise redirect to the login page
u := url.URL{
Path: "/login",
}
q := u.Query()
q.Set(returnURLParam, r.URL.Path)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
return
}
}

ctx = session.SetCurrentUserID(ctx, userID)

r = r.WithContext(ctx)

next.ServeHTTP(w, r)
})
}
}

func checkSecurityTripwireActivated(c *config.Instance, w http.ResponseWriter) bool {
if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil {
w.WriteHeader(http.StatusForbidden)
_, err := w.Write([]byte(tripwireActivatedErrMsg))
if err != nil {
logger.Error(err)
}
return false
}

return true
}

func securityActivateTripwireAccessedFromInternetWithoutAuth(c *config.Instance, accessErr session.ExternalAccessError, w http.ResponseWriter) {
session.LogExternalAccessError(accessErr)

err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String())
if err != nil {
logger.Error(err)
}

w.WriteHeader(http.StatusForbidden)
_, err = w.Write([]byte(externalAccessErrMsg))
if err != nil {
logger.Error(err)
}

err = manager.GetInstance().Shutdown()
if err != nil {
logger.Error(err)
}
}
4 changes: 4 additions & 0 deletions pkg/api/resolver_mutation_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
}

if input.TrustedProxies != nil {
c.Set(config.TrustedProxies, input.TrustedProxies)
}

if input.LogFile != nil {
c.Set(config.LogFile, input.LogFile)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/api/resolver_query_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
Username: config.GetUsername(),
Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(),
TrustedProxies: config.GetTrustedProxies(),
LogFile: &logFile,
LogOut: config.GetLogOut(),
LogLevel: config.GetLogLevel(),
Expand Down
63 changes: 1 addition & 62 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"runtime/debug"
Expand All @@ -29,73 +28,13 @@ import (
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/utils"
)

var version string
var buildstamp string
var githash string

func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/login") || r.URL.Path == "/css"
}

func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil {
if err != session.ErrUnauthorized {
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(err.Error()))
if err != nil {
logger.Error(err)
}
return
}

// unauthorized error
w.Header().Add("WWW-Authenticate", `FormBased`)
w.WriteHeader(http.StatusUnauthorized)
return
}

c := config.GetInstance()
ctx := r.Context()

// handle redirect if no user and user is required
if userID == "" && c.HasCredentials() && !allowUnauthenticated(r) {
// if we don't have a userID, then redirect
// if graphql was requested, we just return a forbidden error
if r.URL.Path == "/graphql" {
w.Header().Add("WWW-Authenticate", `FormBased`)
w.WriteHeader(http.StatusUnauthorized)
return
}

// otherwise redirect to the login page
u := url.URL{
Path: "/login",
}
q := u.Query()
q.Set(returnURLParam, r.URL.Path)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
return
}

ctx = session.SetCurrentUserID(ctx, userID)

r = r.WithContext(ctx)

next.ServeHTTP(w, r)
})
}
}

const loginEndPoint = "/login"

func Start(uiBox embed.FS, loginUIBox embed.FS) {
initialiseImages()

Expand Down Expand Up @@ -274,7 +213,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
}
uiRoot, err := fs.Sub(uiBox, uiRootDir)
if err != nil {
panic(error.Error(err))
panic(err)
}
http.FileServer(http.FS(uiRoot)).ServeHTTP(w, r)
}
Expand Down
43 changes: 43 additions & 0 deletions pkg/manager/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ const SlideshowDelay = "slideshow_delay"
const HandyKey = "handy_key"
const FunscriptOffset = "funscript_offset"

// Security
const TrustedProxies = "trusted_proxies"
const dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth"
const dangerousAllowPublicWithoutAuthDefault = "false"
const SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet"
const securityTripwireAccessedFromPublicInternetDefault = ""

// DLNA options
const DLNAServerName = "dlna.server_name"
const DLNADefaultEnabled = "dlna.default_enabled"
Expand Down Expand Up @@ -838,6 +845,31 @@ func (i *Instance) GetFunscriptOffset() int {
return viper.GetInt(FunscriptOffset)
}

// GetTrustedProxies returns a comma separated list of ip addresses that should allow proxying.
// When empty, allow from any private network
func (i *Instance) GetTrustedProxies() []string {
i.RLock()
defer i.RUnlock()
return viper.GetStringSlice(TrustedProxies)
}

// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
// See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet
func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(dangerousAllowPublicWithoutAuth)
}

// GetSecurityTripwireAccessedFromPublicInternet returns a public IP address if stash
// has been accessed from the public internet, with no auth enabled, and
// DangerousAllowPublicWithoutAuth disabled. Returns an empty string otherwise.
func (i *Instance) GetSecurityTripwireAccessedFromPublicInternet() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(SecurityTripwireAccessedFromPublicInternet)
}

// GetDLNAServerName returns the visible name of the DLNA server. If empty,
// "stash" will be used.
func (i *Instance) GetDLNAServerName() string {
Expand Down Expand Up @@ -930,6 +962,14 @@ func (i *Instance) GetMaxUploadSize() int64 {
return ret << 20
}

// ActivatePublicAccessTripwire sets the security_tripwire_accessed_from_public_internet
// config field to the provided IP address to indicate that stash has been accessed
// from this public IP without authentication.
func (i *Instance) ActivatePublicAccessTripwire(requestIP string) error {
i.Set(SecurityTripwireAccessedFromPublicInternet, requestIP)
return i.Write()
}

func (i *Instance) Validate() error {
i.RLock()
defer i.RUnlock()
Expand Down Expand Up @@ -982,6 +1022,9 @@ func (i *Instance) setDefaultValues(write bool) error {

viper.SetDefault(Database, defaultDatabaseFilePath)

viper.SetDefault(dangerousAllowPublicWithoutAuth, dangerousAllowPublicWithoutAuthDefault)
viper.SetDefault(SecurityTripwireAccessedFromPublicInternet, securityTripwireAccessedFromPublicInternetDefault)

// Set generated to the metadata path for backwards compat
viper.SetDefault(Generated, viper.GetString(Metadata))

Expand Down
8 changes: 8 additions & 0 deletions pkg/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ func Initialize() *singleton {
panic(err)
}
}

initSecurity(cfg)
} else {
cfgFile := cfg.GetConfigFile()
if cfgFile != "" {
Expand Down Expand Up @@ -125,6 +127,12 @@ func Initialize() *singleton {
return instance
}

func initSecurity(cfg *config.Instance) {
if err := session.CheckExternalAccessTripwire(cfg); err != nil {
session.LogExternalAccessError(*err)
}
}

func initProfiling(cpuProfilePath string) {
if cpuProfilePath == "" {
return
Expand Down
Loading

0 comments on commit f1da6cb

Please sign in to comment.