Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disallow access in publicly exposed services #1761

Merged
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphql/documents/data/config.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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 @@ -71,6 +71,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 @@ -148,6 +150,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 @@ -137,6 +137,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 @@ -78,6 +78,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