httpd: allow to restrict allowed hosts ...

... and to add security headers to the responses

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-02-17 18:22:27 +01:00
parent 876bf8aa4f
commit f1a255aa6c
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
15 changed files with 415 additions and 24 deletions

View file

@ -92,6 +92,21 @@ var (
UsernameField: "", UsernameField: "",
RoleField: "", RoleField: "",
}, },
Security: httpd.SecurityConf{
Enabled: false,
AllowedHosts: nil,
AllowedHostsAreRegex: false,
HostsProxyHeaders: nil,
HTTPSProxyHeaders: nil,
STSSeconds: 0,
STSIncludeSubdomains: false,
STSPreload: false,
ContentTypeNosniff: false,
ContentSecurityPolicy: "",
PermissionsPolicy: "",
CrossOriginOpenerPolicy: "",
ExpectCTHeader: "",
},
} }
defaultRateLimiter = common.RateLimiterConfig{ defaultRateLimiter = common.RateLimiterConfig{
Average: 0, Average: 0,
@ -1083,6 +1098,106 @@ func getWebDAVDBindingFromEnv(idx int) {
} }
} }
func getHTTPDSecurityProxyHeadersFromEnv(idx int) []httpd.HTTPSProxyHeader {
var httpsProxyHeaders []httpd.HTTPSProxyHeader
for subIdx := 0; subIdx < 10; subIdx++ {
proxyKey, _ := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__HTTPS_PROXY_HEADERS__%v__KEY", idx, subIdx))
proxyVal, _ := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__HTTPS_PROXY_HEADERS__%v__VALUE", idx, subIdx))
if proxyKey != "" && proxyVal != "" {
httpsProxyHeaders = append(httpsProxyHeaders, httpd.HTTPSProxyHeader{
Key: proxyKey,
Value: proxyVal,
})
}
}
return httpsProxyHeaders
}
func getHTTPDSecurityConfFromEnv(idx int) (httpd.SecurityConf, bool) {
var result httpd.SecurityConf
isSet := false
enabled, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__ENABLED", idx))
if ok {
result.Enabled = enabled
isSet = true
}
allowedHosts, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__ALLOWED_HOSTS", idx))
if ok {
result.AllowedHosts = allowedHosts
isSet = true
}
allowedHostsAreRegex, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__ALLOWED_HOSTS_ARE_REGEX", idx))
if ok {
result.AllowedHostsAreRegex = allowedHostsAreRegex
isSet = true
}
hostsProxyHeaders, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__HOSTS_PROXY_HEADERS", idx))
if ok {
result.HostsProxyHeaders = hostsProxyHeaders
isSet = true
}
httpsProxyHeaders := getHTTPDSecurityProxyHeadersFromEnv(idx)
if len(httpsProxyHeaders) > 0 {
result.HTTPSProxyHeaders = httpsProxyHeaders
isSet = true
}
stsSeconds, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__STS_SECONDS", idx))
if ok {
result.STSSeconds = stsSeconds
}
stsIncludeSubDomains, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__STS_INCLUDE_SUBDOMAINS", idx))
if ok {
result.STSIncludeSubdomains = stsIncludeSubDomains
isSet = true
}
stsPreload, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__STS_PRELOAD", idx))
if ok {
result.STSPreload = stsPreload
isSet = true
}
contentTypeNosniff, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CONTENT_TYPE_NOSNIFF", idx))
if ok {
result.ContentTypeNosniff = contentTypeNosniff
isSet = true
}
contentSecurityPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CONTENT_SECURITY_POLICY", idx))
if ok {
result.ContentSecurityPolicy = contentSecurityPolicy
isSet = true
}
permissionsPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__PERMISSIONS_POLICY", idx))
if ok {
result.PermissionsPolicy = permissionsPolicy
isSet = true
}
crossOriginOpenedPolicy, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__CROSS_ORIGIN_OPENER_POLICY", idx))
if ok {
result.CrossOriginOpenerPolicy = crossOriginOpenedPolicy
isSet = true
}
expectCTHeader, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__SECURITY__EXPECT_CT_HEADER", idx))
if ok {
result.ExpectCTHeader = expectCTHeader
isSet = true
}
return result, isSet
}
func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) { func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) {
var result httpd.OIDC var result httpd.OIDC
isSet := false isSet := false
@ -1246,6 +1361,12 @@ func getHTTPDBindingFromEnv(idx int) {
isSet = true isSet = true
} }
securityConf, ok := getHTTPDSecurityConfFromEnv(idx)
if ok {
binding.Security = securityConf
isSet = true
}
setHTTPDBinding(isSet, binding, idx) setHTTPDBinding(isSet, binding, idx)
} }

View file

@ -819,6 +819,20 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL", "redirect base url") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL", "redirect base url")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD", "preferred_username") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD", "preferred_username")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD", "sftpgo_role") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD", "sftpgo_role")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED", "true")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS", "*.example.com,*.example.net")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX", "1")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__HOSTS_PROXY_HEADERS", "X-Forwarded-Host")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__HTTPS_PROXY_HEADERS__1__KEY", "X-Forwarded-Proto")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__HTTPS_PROXY_HEADERS__1__VALUE", "https")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__STS_SECONDS", "31536000")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__STS_INCLUDE_SUBDOMAINS", "false")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__STS_PRELOAD", "0")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_TYPE_NOSNIFF", "t")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY", "script-src $NONCE")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY", "fullscreen=(), geolocation=()")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY", "same-origin")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__EXPECT_CT_HEADER", `max-age=86400, enforce, report-uri="https://foo.example/report"`)
t.Cleanup(func() { t.Cleanup(func() {
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__ADDRESS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__ADDRESS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__PORT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__PORT")
@ -848,6 +862,20 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__HOSTS_PROXY_HEADERS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__HTTPS_PROXY_HEADERS__1__KEY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__HTTPS_PROXY_HEADERS__1__VALUE")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__STS_SECONDS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__STS_INCLUDE_SUBDOMAINS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__STS_PRELOAD")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_TYPE_NOSNIFF")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CONTENT_SECURITY_POLICY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__PERMISSIONS_POLICY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__CROSS_ORIGIN_OPENER_POLICY")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__EXPECT_CT_HEADER")
}) })
configDir := ".." configDir := ".."
@ -866,6 +894,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Empty(t, bindings[0].OIDC.ConfigURL) require.Empty(t, bindings[0].OIDC.ConfigURL)
require.Equal(t, "TLS_AES_128_GCM_SHA256", bindings[0].TLSCipherSuites[0]) require.Equal(t, "TLS_AES_128_GCM_SHA256", bindings[0].TLSCipherSuites[0])
require.Equal(t, 0, bindings[0].HideLoginURL) require.Equal(t, 0, bindings[0].HideLoginURL)
require.False(t, bindings[0].Security.Enabled)
require.Equal(t, 8000, bindings[1].Port) require.Equal(t, 8000, bindings[1].Port)
require.Equal(t, "127.0.0.1", bindings[1].Address) require.Equal(t, "127.0.0.1", bindings[1].Address)
require.False(t, bindings[1].EnableHTTPS) require.False(t, bindings[1].EnableHTTPS)
@ -876,6 +905,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Nil(t, bindings[1].TLSCipherSuites) require.Nil(t, bindings[1].TLSCipherSuites)
require.Equal(t, 1, bindings[1].HideLoginURL) require.Equal(t, 1, bindings[1].HideLoginURL)
require.Empty(t, bindings[1].OIDC.ClientID) require.Empty(t, bindings[1].OIDC.ClientID)
require.False(t, bindings[1].Security.Enabled)
require.Equal(t, 9000, bindings[2].Port) require.Equal(t, 9000, bindings[2].Port)
require.Equal(t, "127.0.1.1", bindings[2].Address) require.Equal(t, "127.0.1.1", bindings[2].Address)
require.True(t, bindings[2].EnableHTTPS) require.True(t, bindings[2].EnableHTTPS)
@ -900,6 +930,24 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Equal(t, "redirect base url", bindings[2].OIDC.RedirectBaseURL) require.Equal(t, "redirect base url", bindings[2].OIDC.RedirectBaseURL)
require.Equal(t, "preferred_username", bindings[2].OIDC.UsernameField) require.Equal(t, "preferred_username", bindings[2].OIDC.UsernameField)
require.Equal(t, "sftpgo_role", bindings[2].OIDC.RoleField) require.Equal(t, "sftpgo_role", bindings[2].OIDC.RoleField)
require.True(t, bindings[2].Security.Enabled)
require.Len(t, bindings[2].Security.AllowedHosts, 2)
require.Equal(t, "*.example.com", bindings[2].Security.AllowedHosts[0])
require.Equal(t, "*.example.net", bindings[2].Security.AllowedHosts[1])
require.True(t, bindings[2].Security.AllowedHostsAreRegex)
require.Len(t, bindings[2].Security.HostsProxyHeaders, 1)
require.Equal(t, "X-Forwarded-Host", bindings[2].Security.HostsProxyHeaders[0])
require.Len(t, bindings[2].Security.HTTPSProxyHeaders, 1)
require.Equal(t, "X-Forwarded-Proto", bindings[2].Security.HTTPSProxyHeaders[0].Key)
require.Equal(t, "https", bindings[2].Security.HTTPSProxyHeaders[0].Value)
require.Equal(t, int64(31536000), bindings[2].Security.STSSeconds)
require.False(t, bindings[2].Security.STSIncludeSubdomains)
require.False(t, bindings[2].Security.STSPreload)
require.True(t, bindings[2].Security.ContentTypeNosniff)
require.Equal(t, "script-src $NONCE", bindings[2].Security.ContentSecurityPolicy)
require.Equal(t, "fullscreen=(), geolocation=()", bindings[2].Security.PermissionsPolicy)
require.Equal(t, "same-origin", bindings[2].Security.CrossOriginOpenerPolicy)
require.Equal(t, `max-age=86400, enforce, report-uri="https://foo.example/report"`, bindings[2].Security.ExpectCTHeader)
} }
func TestHTTPClientCertificatesFromEnv(t *testing.T) { func TestHTTPClientCertificatesFromEnv(t *testing.T) {

View file

@ -230,7 +230,7 @@ The configuration file contains the following sections:
- `min_tls_version`, integer. Defines the minimum version of TLS to be enabled. `12` means TLS 1.2 (and therefore TLS 1.2 and TLS 1.3 will be enabled),`13` means TLS 1.3. Default: `12`. - `min_tls_version`, integer. Defines the minimum version of TLS to be enabled. `12` means TLS 1.2 (and therefore TLS 1.2 and TLS 1.3 will be enabled),`13` means TLS 1.3. Default: `12`.
- `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to JWT/Web authentication. You need to define at least a certificate authority for this to work. Default: 0. - `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to JWT/Web authentication. You need to define at least a certificate authority for this to work. Default: 0.
- `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: blank. - `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: blank.
- `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: blank. - `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` and any other headers defined in the `security` section. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: blank.
- `hide_login_url`, integer. If both web admin and web client are enabled each login page will show a link to the other one. This setting allows to hide this link. 0 means that the login links are displayed on both admin and client login page. This is the default. 1 means that the login link to the web client login page is hidden on admin login page. 2 means that the login link to the web admin login page is hidden on client login page. The flags can be combined, for example 3 will disable both login links. - `hide_login_url`, integer. If both web admin and web client are enabled each login page will show a link to the other one. This setting allows to hide this link. 0 means that the login links are displayed on both admin and client login page. This is the default. 1 means that the login link to the web client login page is hidden on admin login page. 2 means that the login link to the web admin login page is hidden on client login page. The flags can be combined, for example 3 will disable both login links.
- `render_openapi`, boolean. Set to `false` to disable serving of the OpenAPI schema and renderer. Default `true`. - `render_openapi`, boolean. Set to `false` to disable serving of the OpenAPI schema and renderer. Default `true`.
- `web_client_integrations`, list of struct. The SFTPGo web client allows to send the files with the specified extensions to the configured URL using the [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). This way you can integrate your own file viewer or editor. Take a look at the commentented example [here](../examples/webclient-integrations/test.html) to understand how to use this feature. Each struct has the following fields: - `web_client_integrations`, list of struct. The SFTPGo web client allows to send the files with the specified extensions to the configured URL using the [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). This way you can integrate your own file viewer or editor. Take a look at the commentented example [here](../examples/webclient-integrations/test.html) to understand how to use this feature. Each struct has the following fields:
@ -243,6 +243,20 @@ The configuration file contains the following sections:
- `redirect_base_url`, string. Defines the base URL to redirect to after OpenID authentication. The suffix `/web/oidc/redirect` will be added to this base URL, adding also the `web_root` if configured. Default: blank. - `redirect_base_url`, string. Defines the base URL to redirect to after OpenID authentication. The suffix `/web/oidc/redirect` will be added to this base URL, adding also the `web_root` if configured. Default: blank.
- `username_field`, string. Defines the ID token claims field to map to the SFTPGo username. Default: blank. - `username_field`, string. Defines the ID token claims field to map to the SFTPGo username. Default: blank.
- `role_field`, string. Defines the optional ID token claims field to map to a SFTPGo role. If the defined ID token claims field is set to `admin` the authenticated user is mapped to an SFTPGo admin. You don't need to specify this field if you want to use OpenID only for the Web Client UI. Default: blank. - `role_field`, string. Defines the optional ID token claims field to map to a SFTPGo role. If the defined ID token claims field is set to `admin` the authenticated user is mapped to an SFTPGo admin. You don't need to specify this field if you want to use OpenID only for the Web Client UI. Default: blank.
- `security`, struct. Defines security headers to add to HTTP responses and allows to restrict allowed hosts. The following parameters are supported:
- `enabled`, boolean. Set to `true` to enable security configurations. Default: `false`.
- `allowed_hosts`, list of strings. Fully qualified domain names that are allowed. An empty list allows any and all host names. Default: empty.
- `allowed_hosts_are_regex`, boolean. Determines if the provided allowed hosts contains valid regular expressions. Default: `false`.
- `hosts_proxy_headers`, list of string. Defines a set of header keys that may hold a proxied hostname value for the request, for example `X-Forwarded-Host`. Default: empty.
- `https_proxy_headers`, list of struct, each struct contains the fields `key` and `value`. Defines a a list of header keys with associated values that would indicate a valid https request. For example `key` could be `X-Forwarded-Proto` and `value` `https`. Default: empty.
- `sts_seconds`, integer. Defines the max-age of the `Strict-Transport-Security` header. This header will be included for `https` responses or for HTTP request if the request includes a defined HTTPS proxy header. Default: `0`, which would NOT include the header.
- `sts_include_subdomains`, boolean. Set to `true`, the `includeSubdomains` will be appended to the `Strict-Transport-Security` header. Default: `false`.
- `sts_preload`, boolean. Set to true, the `preload` flag will be appended to the `Strict-Transport-Security` header. Default: `false`.
- `content_type_nosniff`, boolean. Set to `true` to add the `X-Content-Type-Options` header with the value `nosniff` Default: `false`.
- `content_security_policy`, string. Allows to set the `Content-Security-Policy` header value. Default: blank.
- `permissions_policy`, string. Allows to set the `Permissions-Policy` header value. Default: blank.
- `cross_origin_opener_policy`, string. Allows to set the `Cross-Origin-Opener-Policy` header value. Default: blank.
- `expect_ct_header`, string. Allows to set the `Expect-CT` header value. Default: blank.
- `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir - `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir
- `static_files_path`, string. Path to the static files for the web interface. This can be an absolute path or a path relative to the config dir. If both `templates_path` and `static_files_path` are empty the built-in web interface will be disabled - `static_files_path`, string. Path to the static files for the web interface. This can be an absolute path or a path relative to the config dir. If both `templates_path` and `static_files_path` are empty the built-in web interface will be disabled
- `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons - `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons
@ -255,7 +269,7 @@ The configuration file contains the following sections:
- `signing_passphrase`, string. Passphrase to use to derive the signing key for JWT and CSRF tokens. If empty a random signing key will be generated each time SFTPGo starts. If you set a signing passphrase you should consider rotating it periodically for added security. - `signing_passphrase`, string. Passphrase to use to derive the signing key for JWT and CSRF tokens. If empty a random signing key will be generated each time SFTPGo starts. If you set a signing passphrase you should consider rotating it periodically for added security.
- `max_upload_file_size`, integer. Defines the maximum request body size, in bytes, for Web Client/API HTTP upload requests. 0 means no limit. Default: 1048576000. - `max_upload_file_size`, integer. Defines the maximum request body size, in bytes, for Web Client/API HTTP upload requests. 0 means no limit. Default: 1048576000.
- `cors` struct containing CORS configuration. SFTPGo uses [Go CORS handler](https://github.com/rs/cors), please refer to upstream documentation for fields meaning and their default values. - `cors` struct containing CORS configuration. SFTPGo uses [Go CORS handler](https://github.com/rs/cors), please refer to upstream documentation for fields meaning and their default values.
- `enabled`, boolean, set to true to enable CORS. - `enabled`, boolean, set to `true` to enable CORS.
- `allowed_origins`, list of strings. - `allowed_origins`, list of strings.
- `allowed_methods`, list of strings. - `allowed_methods`, list of strings.
- `allowed_headers`, list of strings. - `allowed_headers`, list of strings.

View file

@ -354,7 +354,7 @@ func (c *Connection) uploadFile(fs vfs.Fs, fsPath, ftpPath string, flags int) (f
if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(ftpPath)) { if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(ftpPath)) {
return nil, fmt.Errorf("%w, no upload permission", ftpserver.ErrFileNameNotAllowed) return nil, fmt.Errorf("%w, no upload permission", ftpserver.ErrFileNameNotAllowed)
} }
return c.handleFTPUploadToNewFile(fs, fsPath, filePath, ftpPath) return c.handleFTPUploadToNewFile(fs, flags, fsPath, filePath, ftpPath)
} }
if statErr != nil { if statErr != nil {
@ -375,7 +375,7 @@ func (c *Connection) uploadFile(fs vfs.Fs, fsPath, ftpPath string, flags int) (f
return c.handleFTPUploadToExistingFile(fs, flags, fsPath, filePath, stat.Size(), ftpPath) return c.handleFTPUploadToExistingFile(fs, flags, fsPath, filePath, stat.Size(), ftpPath)
} }
func (c *Connection) handleFTPUploadToNewFile(fs vfs.Fs, resolvedPath, filePath, requestPath string) (ftpserver.FileTransfer, error) { func (c *Connection) handleFTPUploadToNewFile(fs vfs.Fs, flags int, resolvedPath, filePath, requestPath string) (ftpserver.FileTransfer, error) {
diskQuota, transferQuota := c.HasSpace(true, false, requestPath) diskQuota, transferQuota := c.HasSpace(true, false, requestPath)
if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() { if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() {
c.Log(logger.LevelInfo, "denying file write due to quota limits") c.Log(logger.LevelInfo, "denying file write due to quota limits")
@ -385,9 +385,9 @@ func (c *Connection) handleFTPUploadToNewFile(fs vfs.Fs, resolvedPath, filePath,
c.Log(logger.LevelDebug, "upload for file %#v denied by pre action: %v", requestPath, err) c.Log(logger.LevelDebug, "upload for file %#v denied by pre action: %v", requestPath, err)
return nil, fmt.Errorf("%w, denied by pre-upload action", ftpserver.ErrFileNameNotAllowed) return nil, fmt.Errorf("%w, denied by pre-upload action", ftpserver.ErrFileNameNotAllowed)
} }
file, w, cancelFn, err := fs.Create(filePath, 0) file, w, cancelFn, err := fs.Create(filePath, flags)
if err != nil { if err != nil {
c.Log(logger.LevelError, "error creating file %#v: %+v", resolvedPath, err) c.Log(logger.LevelError, "error creating file %#v, flags %v: %+v", resolvedPath, flags, err)
return nil, c.GetFsError(fs, err) return nil, c.GetFsError(fs, err)
} }

9
go.mod
View file

@ -7,7 +7,7 @@ require (
github.com/Azure/azure-storage-blob-go v0.14.0 github.com/Azure/azure-storage-blob-go v0.14.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/aws/aws-sdk-go v1.42.53 github.com/aws/aws-sdk-go v1.43.0
github.com/cockroachdb/cockroach-go/v2 v2.2.8 github.com/cockroachdb/cockroach-go/v2 v2.2.8
github.com/coreos/go-oidc/v3 v3.1.0 github.com/coreos/go-oidc/v3 v3.1.0
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
@ -25,7 +25,7 @@ require (
github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-retryablehttp v0.7.0 github.com/hashicorp/go-retryablehttp v0.7.0
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.14.2 github.com/klauspost/compress v1.14.3
github.com/lestrrat-go/jwx v1.2.18 github.com/lestrrat-go/jwx v1.2.18
github.com/lib/pq v1.10.4 github.com/lib/pq v1.10.4
github.com/lithammer/shortuuid/v3 v3.0.7 github.com/lithammer/shortuuid/v3 v3.0.7
@ -47,6 +47,7 @@ require (
github.com/spf13/viper v1.10.1 github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.0 github.com/stretchr/testify v1.7.0
github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62 github.com/studio-b12/gowebdav v0.0.0-20220128162035-c7b1ff8a5e62
github.com/unrolled/secure v1.10.0
github.com/wagslane/go-password-validator v0.3.0 github.com/wagslane/go-password-validator v0.3.0
github.com/xhit/go-simple-mail/v2 v2.10.0 github.com/xhit/go-simple-mail/v2 v2.10.0
github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
@ -109,7 +110,7 @@ require (
github.com/pelletier/go-toml v1.9.4 // indirect github.com/pelletier/go-toml v1.9.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect github.com/prometheus/procfs v0.7.3 // indirect
@ -127,7 +128,7 @@ require (
golang.org/x/tools v0.1.9 // indirect golang.org/x/tools v0.1.9 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220215190005-e57b466719ef // indirect google.golang.org/genproto v0.0.0-20220217155828-d576998c0009 // indirect
google.golang.org/grpc v1.44.0 // indirect google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/ini.v1 v1.66.4 // indirect

17
go.sum
View file

@ -143,8 +143,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.53 h1:56T04NWcmc0ZVYFbUc6HdewDQ9iHQFlmS6hj96dRjJs= github.com/aws/aws-sdk-go v1.43.0 h1:y4UrPbxU/mIL08qksVPE/nwH9IXuC1udjOaNyhEe+pI=
github.com/aws/aws-sdk-go v1.42.53/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc= github.com/aws/aws-sdk-go v1.43.0/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY= github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY= github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@ -511,8 +511,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.14.2 h1:S0OHlFk/Gbon/yauFJ4FfJJF5V0fc5HbBTJazi28pRw= github.com/klauspost/compress v1.14.3 h1:DQv1WP+iS4srNjibdnHtqu8JNWCDMluj5NzPnFJsnvk=
github.com/klauspost/compress v1.14.2/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.14.3/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.11 h1:i2lw1Pm7Yi/4O6XCSyJWqEHI2MDw2FzUK6o/D21xn2A= github.com/klauspost/cpuid/v2 v2.0.11 h1:i2lw1Pm7Yi/4O6XCSyJWqEHI2MDw2FzUK6o/D21xn2A=
github.com/klauspost/cpuid/v2 v2.0.11/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.11/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@ -645,8 +645,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@ -743,6 +744,8 @@ github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hM
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/unrolled/secure v1.10.0 h1:TBNP42z2AB+2pW9PR6vdbqhlQuv1iTeSVzK1qHjOBzA=
github.com/unrolled/secure v1.10.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
github.com/xhit/go-simple-mail/v2 v2.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8= github.com/xhit/go-simple-mail/v2 v2.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8=
@ -1174,8 +1177,8 @@ google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220215190005-e57b466719ef h1:LgGaJzny+/at3jTXZUNh/l8VBWyAiskCHrwq6iEYE7I= google.golang.org/genproto v0.0.0-20220217155828-d576998c0009 h1:8QEZX8dJDqdCxQVLRWzEKGOkOzuDx0AU4+bQX6LwmU4=
google.golang.org/genproto v0.0.0-20220215190005-e57b466719ef/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220217155828-d576998c0009/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

View file

@ -190,7 +190,7 @@ func (c *Connection) handleUploadFile(fs vfs.Fs, resolvedPath, filePath, request
maxWriteSize, _ := c.GetMaxWriteSize(diskQuota, false, fileSize, fs.IsUploadResumeSupported()) maxWriteSize, _ := c.GetMaxWriteSize(diskQuota, false, fileSize, fs.IsUploadResumeSupported())
file, w, cancelFn, err := fs.Create(filePath, 0) file, w, cancelFn, err := fs.Create(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil { if err != nil {
c.Log(logger.LevelError, "error opening existing file, source: %#v, err: %+v", filePath, err) c.Log(logger.LevelError, "error opening existing file, source: %#v, err: %+v", filePath, err)
return nil, c.GetFsError(fs, err) return nil, c.GetFsError(fs, err)

View file

@ -236,6 +236,67 @@ func init() {
updateWebClientURLs("") updateWebClientURLs("")
} }
// HTTPSProxyHeader defines an HTTPS proxy header as key/value.
// For example Key could be "X-Forwarded-Proto" and Value "https"
type HTTPSProxyHeader struct {
Key string
Value string
}
// SecurityConf allows to add some security related headers to HTTP responses and to restrict allowed hosts
type SecurityConf struct {
// Set to true to enable the security configurations
Enabled bool `json:"enabled" mapstructure:"enabled"`
// AllowedHosts is a list of fully qualified domain names that are allowed.
// Default is empty list, which allows any and all host names.
AllowedHosts []string `json:"allowed_hosts" mapstructure:"allowed_hosts"`
// AllowedHostsAreRegex determines if the provided allowed hosts contains valid regular expressions
AllowedHostsAreRegex bool `json:"allowed_hosts_are_regex" mapstructure:"allowed_hosts_are_regex"`
// HostsProxyHeaders is a set of header keys that may hold a proxied hostname value for the request.
HostsProxyHeaders []string `json:"hosts_proxy_headers" mapstructure:"hosts_proxy_headers"`
// HTTPSProxyHeaders is a list of header keys with associated values that would indicate a valid https request.
HTTPSProxyHeaders []HTTPSProxyHeader `json:"https_proxy_headers" mapstructure:"https_proxy_headers"`
// STSSeconds is the max-age of the Strict-Transport-Security header.
// Default is 0, which would NOT include the header.
STSSeconds int64 `json:"sts_seconds" mapstructure:"sts_seconds"`
// If STSIncludeSubdomains is set to true, the "includeSubdomains" will be appended to the
// Strict-Transport-Security header. Default is false.
STSIncludeSubdomains bool `json:"sts_include_subdomains" mapstructure:"sts_include_subdomains"`
// If STSPreload is set to true, the `preload` flag will be appended to the
// Strict-Transport-Security header. Default is false.
STSPreload bool `json:"sts_preload" mapstructure:"sts_preload"`
// If ContentTypeNosniff is true, adds the X-Content-Type-Options header with the value "nosniff". Default is false.
ContentTypeNosniff bool `json:"content_type_nosniff" mapstructure:"content_type_nosniff"`
// ContentSecurityPolicy allows to set the Content-Security-Policy header value. Default is "".
ContentSecurityPolicy string `json:"content_security_policy" mapstructure:"content_security_policy"`
// PermissionsPolicy allows to set the Permissions-Policy header value. Default is "".
PermissionsPolicy string `json:"permissions_policy" mapstructure:"permissions_policy"`
// CrossOriginOpenerPolicy allows to set the `Cross-Origin-Opener-Policy` header value. Default is "".
CrossOriginOpenerPolicy string `json:"cross_origin_opener_policy" mapstructure:"cross_origin_opener_policy"`
// ExpectCTHeader allows to set the Expect-CT header value. Default is "".
ExpectCTHeader string `json:"expect_ct_header" mapstructure:"expect_ct_header"`
proxyHeaders []string
}
func (s *SecurityConf) updateProxyHeaders() {
if !s.Enabled {
s.proxyHeaders = nil
return
}
s.proxyHeaders = s.HostsProxyHeaders
for _, httpsProxyHeader := range s.HTTPSProxyHeaders {
s.proxyHeaders = append(s.proxyHeaders, httpsProxyHeader.Key)
}
}
func (s *SecurityConf) getHTTPSProxyHeaders() map[string]string {
headers := make(map[string]string)
for _, httpsProxyHeader := range s.HTTPSProxyHeaders {
headers[httpsProxyHeader.Key] = httpsProxyHeader.Value
}
return headers
}
// WebClientIntegration defines the configuration for an external Web Client integration // WebClientIntegration defines the configuration for an external Web Client integration
type WebClientIntegration struct { type WebClientIntegration struct {
// Files with these extensions can be sent to the configured URL // Files with these extensions can be sent to the configured URL
@ -290,7 +351,9 @@ type Binding struct {
// extensions using an external tool. // extensions using an external tool.
WebClientIntegrations []WebClientIntegration `json:"web_client_integrations" mapstructure:"web_client_integrations"` WebClientIntegrations []WebClientIntegration `json:"web_client_integrations" mapstructure:"web_client_integrations"`
// Defining an OIDC configuration the web admin and web client UI will use OpenID to authenticate users. // Defining an OIDC configuration the web admin and web client UI will use OpenID to authenticate users.
OIDC OIDC `json:"oidc" mapstructure:"oidc"` OIDC OIDC `json:"oidc" mapstructure:"oidc"`
// Security defines security headers to add to HTTP responses and allows to restrict allowed hosts
Security SecurityConf `json:"security" mapstructure:"security"`
allowHeadersFrom []func(net.IP) bool allowHeadersFrom []func(net.IP) bool
} }
@ -527,6 +590,7 @@ func (c *Conf) Initialize(configDir string) error {
return err return err
} }
binding.checkWebClientIntegrations() binding.checkWebClientIntegrations()
binding.Security.updateProxyHeaders()
go func(b Binding) { go func(b Binding) {
if err := b.OIDC.initialize(); err != nil { if err := b.OIDC.initialize(); err != nil {

View file

@ -338,6 +338,15 @@ func TestMain(m *testing.M) {
httpdConf := config.GetHTTPDConfig() httpdConf := config.GetHTTPDConfig()
httpdConf.Bindings[0].Port = 8081 httpdConf.Bindings[0].Port = 8081
httpdConf.Bindings[0].Security = httpd.SecurityConf{
Enabled: true,
HTTPSProxyHeaders: []httpd.HTTPSProxyHeader{
{
Key: "X-Forwarded-Proto",
Value: "https",
},
},
}
httpdtest.SetBaseURL(httpBaseURL) httpdtest.SetBaseURL(httpBaseURL)
backupsPath = filepath.Join(os.TempDir(), "test_backups") backupsPath = filepath.Join(os.TempDir(), "test_backups")
httpdConf.BackupsPath = backupsPath httpdConf.BackupsPath = backupsPath

View file

@ -2230,3 +2230,81 @@ func TestBrowsableSharePaths(t *testing.T) {
Paths: []string{"/a", "/b"}, Paths: []string{"/a", "/b"},
} }
} }
func TestSecureMiddlewareIntegration(t *testing.T) {
forwardedHostHeader := "X-Forwarded-Host"
server := httpdServer{
binding: Binding{
ProxyAllowed: []string{"192.168.1.0/24"},
Security: SecurityConf{
Enabled: true,
AllowedHosts: []string{"*.sftpgo.com"},
AllowedHostsAreRegex: true,
HostsProxyHeaders: []string{forwardedHostHeader},
HTTPSProxyHeaders: []HTTPSProxyHeader{
{
Key: xForwardedProto,
Value: "https",
},
},
STSSeconds: 31536000,
STSIncludeSubdomains: true,
STSPreload: true,
ContentTypeNosniff: true,
},
},
enableWebAdmin: true,
enableWebClient: true,
}
server.binding.Security.updateProxyHeaders()
err := server.binding.parseAllowedProxy()
assert.NoError(t, err)
assert.Equal(t, []string{forwardedHostHeader, xForwardedProto}, server.binding.Security.proxyHeaders)
assert.Equal(t, map[string]string{xForwardedProto: "https"}, server.binding.Security.getHTTPSProxyHeaders())
server.initializeRouter()
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webClientLoginPath, nil)
assert.NoError(t, err)
r.Host = "127.0.0.1"
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusForbidden, rr.Code)
rr = httptest.NewRecorder()
r.Header.Set(forwardedHostHeader, "www.sftpgo.com")
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusForbidden, rr.Code)
// the header should be removed
assert.Empty(t, r.Header.Get(forwardedHostHeader))
rr = httptest.NewRecorder()
r.Host = "test.sftpgo.com"
r.Header.Set(forwardedHostHeader, "test.example.com")
r.RemoteAddr = "192.168.1.1"
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.NotEmpty(t, r.Header.Get(forwardedHostHeader))
rr = httptest.NewRecorder()
r.Header.Set(forwardedHostHeader, "www.sftpgo.com")
r.RemoteAddr = "192.168.1.1"
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
assert.NotEmpty(t, r.Header.Get(forwardedHostHeader))
assert.Empty(t, rr.Header().Get("Strict-Transport-Security"))
assert.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options"))
// now set the X-Forwarded-Proto to https, we should get the Strict-Transport-Security header
rr = httptest.NewRecorder()
r.Host = "test.sftpgo.com"
r.Header.Set(xForwardedProto, "https")
r.RemoteAddr = "192.168.1.3"
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
assert.NotEmpty(t, r.Header.Get(forwardedHostHeader))
assert.Equal(t, "max-age=31536000; includeSubDomains; preload", rr.Header().Get("Strict-Transport-Security"))
assert.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options"))
server.binding.Security.Enabled = false
server.binding.Security.updateProxyHeaders()
assert.Len(t, server.binding.Security.proxyHeaders, 0)
}

View file

@ -20,6 +20,7 @@ import (
"github.com/rs/cors" "github.com/rs/cors"
"github.com/rs/xid" "github.com/rs/xid"
"github.com/sftpgo/sdk" "github.com/sftpgo/sdk"
"github.com/unrolled/secure"
"github.com/drakkan/sftpgo/v2/common" "github.com/drakkan/sftpgo/v2/common"
"github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/dataprovider"
@ -939,6 +940,7 @@ func (s *httpdServer) checkConnection(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
ip := net.ParseIP(ipAddr) ip := net.ParseIP(ipAddr)
areHeadersAllowed := false
if ip != nil { if ip != nil {
for _, allow := range s.binding.allowHeadersFrom { for _, allow := range s.binding.allowHeadersFrom {
if allow(ip) { if allow(ip) {
@ -951,10 +953,16 @@ func (s *httpdServer) checkConnection(next http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), forwardedProtoKey, forwardedProto) ctx := context.WithValue(r.Context(), forwardedProtoKey, forwardedProto)
r = r.WithContext(ctx) r = r.WithContext(ctx)
} }
areHeadersAllowed = true
break break
} }
} }
} }
if !areHeadersAllowed {
for idx := range s.binding.Security.proxyHeaders {
r.Header.Del(s.binding.Security.proxyHeaders[idx])
}
}
common.Connections.AddClientConnection(ipAddr) common.Connections.AddClientConnection(ipAddr)
defer common.Connections.RemoveClientConnection(ipAddr) defer common.Connections.RemoveClientConnection(ipAddr)
@ -1008,6 +1016,17 @@ func (s *httpdServer) sendForbiddenResponse(w http.ResponseWriter, r *http.Reque
sendAPIResponse(w, r, errors.New(message), message, http.StatusForbidden) sendAPIResponse(w, r, errors.New(message), message, http.StatusForbidden)
} }
func (s *httpdServer) badHostHandler(w http.ResponseWriter, r *http.Request) {
host := r.Host
for _, header := range s.binding.Security.HostsProxyHeaders {
if h := r.Header.Get(header); h != "" {
host = h
break
}
}
s.sendForbiddenResponse(w, r, fmt.Sprintf("The host %#v is not allowed", host))
}
func (s *httpdServer) redirectToWebPath(w http.ResponseWriter, r *http.Request, webPath string) { func (s *httpdServer) redirectToWebPath(w http.ResponseWriter, r *http.Request, webPath string) {
if dataprovider.HasAdmin() { if dataprovider.HasAdmin() {
http.Redirect(w, r, webPath, http.StatusFound) http.Redirect(w, r, webPath, http.StatusFound)
@ -1037,6 +1056,24 @@ func (s *httpdServer) initializeRouter() {
s.router.Use(s.checkConnection) s.router.Use(s.checkConnection)
s.router.Use(logger.NewStructuredLogger(logger.GetLogger())) s.router.Use(logger.NewStructuredLogger(logger.GetLogger()))
s.router.Use(middleware.Recoverer) s.router.Use(middleware.Recoverer)
if s.binding.Security.Enabled {
secureMiddleware := secure.New(secure.Options{
AllowedHosts: s.binding.Security.AllowedHosts,
AllowedHostsAreRegex: s.binding.Security.AllowedHostsAreRegex,
HostsProxyHeaders: s.binding.Security.HostsProxyHeaders,
SSLProxyHeaders: s.binding.Security.getHTTPSProxyHeaders(),
STSSeconds: s.binding.Security.STSSeconds,
STSIncludeSubdomains: s.binding.Security.STSIncludeSubdomains,
STSPreload: s.binding.Security.STSPreload,
ContentTypeNosniff: s.binding.Security.ContentTypeNosniff,
ContentSecurityPolicy: s.binding.Security.ContentSecurityPolicy,
PermissionsPolicy: s.binding.Security.PermissionsPolicy,
CrossOriginOpenerPolicy: s.binding.Security.CrossOriginOpenerPolicy,
ExpectCTHeader: s.binding.Security.ExpectCTHeader,
})
secureMiddleware.SetBadHostHandler(http.HandlerFunc(s.badHostHandler))
s.router.Use(secureMiddleware.Handler)
}
if s.cors.Enabled { if s.cors.Enabled {
c := cors.New(cors.Options{ c := cors.New(cors.Options{
AllowedOrigins: s.cors.AllowedOrigins, AllowedOrigins: s.cors.AllowedOrigins,

View file

@ -360,7 +360,7 @@ func (c *Connection) handleSFTPUploadToNewFile(fs vfs.Fs, pflags sftp.FileOpenFl
osFlags := getOSOpenFlags(pflags) osFlags := getOSOpenFlags(pflags)
file, w, cancelFn, err := fs.Create(filePath, osFlags) file, w, cancelFn, err := fs.Create(filePath, osFlags)
if err != nil { if err != nil {
c.Log(logger.LevelError, "error creating file %#v: %+v", resolvedPath, err) c.Log(logger.LevelError, "error creating file %#vm os flags %v, pflags %+v: %+v", resolvedPath, osFlags, pflags, err)
return nil, c.GetFsError(fs, err) return nil, c.GetFsError(fs, err)
} }
@ -416,7 +416,8 @@ func (c *Connection) handleSFTPUploadToExistingFile(fs vfs.Fs, pflags sftp.FileO
file, w, cancelFn, err := fs.Create(filePath, osFlags) file, w, cancelFn, err := fs.Create(filePath, osFlags)
if err != nil { if err != nil {
c.Log(logger.LevelError, "error opening existing file, flags: %v, source: %#v, err: %+v", pflags, filePath, err) c.Log(logger.LevelError, "error opening existing file, os flags %v, pflags: %+v, source: %#v, err: %+v",
osFlags, pflags, filePath, err)
return nil, c.GetFsError(fs, err) return nil, c.GetFsError(fs, err)
} }

View file

@ -230,7 +230,7 @@ func (c *scpCommand) handleUploadFile(fs vfs.Fs, resolvedPath, filePath string,
maxWriteSize, _ := c.connection.GetMaxWriteSize(diskQuota, false, fileSize, fs.IsUploadResumeSupported()) maxWriteSize, _ := c.connection.GetMaxWriteSize(diskQuota, false, fileSize, fs.IsUploadResumeSupported())
file, w, cancelFn, err := fs.Create(filePath, 0) file, w, cancelFn, err := fs.Create(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil { if err != nil {
c.connection.Log(logger.LevelError, "error creating file %#v: %v", resolvedPath, err) c.connection.Log(logger.LevelError, "error creating file %#v: %v", resolvedPath, err)
c.sendErrorMessage(fs, err) c.sendErrorMessage(fs, err)

View file

@ -227,6 +227,21 @@
"redirect_base_url": "", "redirect_base_url": "",
"username_field": "", "username_field": "",
"role_field": "" "role_field": ""
},
"security": {
"enabled": false,
"allowed_hosts": [],
"allowed_hosts_are_regex": false,
"hosts_proxy_headers": [],
"https_proxy_headers": [],
"sts_seconds": 0,
"sts_include_subdomains": false,
"sts_preload": false,
"content_type_nosniff": false,
"content_security_policy": "",
"permissions_policy": "",
"cross_origin_opener_policy": "",
"expect_ct_header": ""
} }
} }
], ],

View file

@ -202,7 +202,7 @@ func (c *Connection) handleUploadToNewFile(fs vfs.Fs, resolvedPath, filePath, re
c.Log(logger.LevelDebug, "upload for file %#v denied by pre action: %v", requestPath, err) c.Log(logger.LevelDebug, "upload for file %#v denied by pre action: %v", requestPath, err)
return nil, c.GetPermissionDeniedError() return nil, c.GetPermissionDeniedError()
} }
file, w, cancelFn, err := fs.Create(filePath, 0) file, w, cancelFn, err := fs.Create(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil { if err != nil {
c.Log(logger.LevelError, "error creating file %#v: %+v", resolvedPath, err) c.Log(logger.LevelError, "error creating file %#v: %+v", resolvedPath, err)
return nil, c.GetFsError(fs, err) return nil, c.GetFsError(fs, err)
@ -247,7 +247,7 @@ func (c *Connection) handleUploadToExistingFile(fs vfs.Fs, resolvedPath, filePat
} }
} }
file, w, cancelFn, err := fs.Create(filePath, 0) file, w, cancelFn, err := fs.Create(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil { if err != nil {
c.Log(logger.LevelError, "error creating file %#v: %+v", resolvedPath, err) c.Log(logger.LevelError, "error creating file %#v: %+v", resolvedPath, err)
return nil, c.GetFsError(fs, err) return nil, c.GetFsError(fs, err)