mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* Validate Azure storage account name format For the commercial and government clouds the storage account name becomes part of the service hostname, so enforce Azure's documented format (3 to 24 lowercase letters and digits) both in config validation and when building the service URL. Also treat an empty AzureCloud as commercial, consistent with the URL builder. * Route Azure custom endpoints through httpservice The Azure custom-cloud backend now issues requests through httpservice's transport rather than the SDK default, so they honor ServiceSettings.AllowedUntrustedInternalConnections like other outbound connections. The commercial and government clouds keep the SDK default transport, which Private Link deployments rely on. The allowlist value is plumbed into FileBackendSettings, and the host/IP checks are factored into a shared httpservice helper that MakeTransport now uses too.
467 lines
15 KiB
Go
467 lines
15 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package filestore
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
func TestBuildAzureServiceURL(t *testing.T) {
|
|
const account = "acmemattermost"
|
|
|
|
tests := []struct {
|
|
name string
|
|
cloud string
|
|
scheme string
|
|
account string // defaults to the package-level account const when empty
|
|
endpoint string
|
|
expected string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "commercial cloud, default scheme",
|
|
cloud: model.AzureCloudCommercial,
|
|
scheme: "https",
|
|
expected: "https://acmemattermost.blob.core.windows.net/",
|
|
},
|
|
{
|
|
name: "commercial cloud rejects an account name with a hash character",
|
|
cloud: model.AzureCloudCommercial,
|
|
scheme: "https",
|
|
account: "account#",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "commercial cloud rejects an account name with a slash character",
|
|
cloud: model.AzureCloudCommercial,
|
|
scheme: "https",
|
|
account: "account/",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "government cloud rejects a malformed account name",
|
|
cloud: model.AzureCloudGovernment,
|
|
scheme: "https",
|
|
account: "account#",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty cloud falls back to commercial for legacy configs",
|
|
cloud: "",
|
|
scheme: "https",
|
|
expected: "https://acmemattermost.blob.core.windows.net/",
|
|
},
|
|
{
|
|
name: "government cloud uses the usgovcloudapi suffix",
|
|
cloud: model.AzureCloudGovernment,
|
|
scheme: "https",
|
|
expected: "https://acmemattermost.blob.core.usgovcloudapi.net/",
|
|
},
|
|
{
|
|
name: "custom cloud returns the endpoint verbatim (vhost-style)",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "https",
|
|
endpoint: "https://acmemattermost.blob.core.windows.net/",
|
|
expected: "https://acmemattermost.blob.core.windows.net/",
|
|
},
|
|
{
|
|
name: "custom cloud returns the endpoint verbatim (Azurite path-style)",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "http",
|
|
endpoint: "http://localhost:10000/devstoreaccount1/",
|
|
expected: "http://localhost:10000/devstoreaccount1/",
|
|
},
|
|
{
|
|
name: "custom cloud preserves arbitrary paths the admin provides",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "https",
|
|
endpoint: "https://blob-proxy.internal.example.com/some/prefix/account/",
|
|
expected: "https://blob-proxy.internal.example.com/some/prefix/account/",
|
|
},
|
|
{
|
|
name: "custom cloud rejects an empty endpoint",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "https",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "custom cloud rejects an endpoint missing the scheme",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "https",
|
|
endpoint: "acmemattermost.blob.core.windows.net/",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "custom cloud rejects an endpoint with no host",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "https",
|
|
endpoint: "https:///acmemattermost/",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "custom cloud rejects a non-HTTP scheme",
|
|
cloud: model.AzureCloudCustom,
|
|
scheme: "https",
|
|
endpoint: "ftp://blob.example.com/",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unknown cloud value is rejected",
|
|
cloud: "azuregermany",
|
|
scheme: "https",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
acct := account
|
|
if tt.account != "" {
|
|
acct = tt.account
|
|
}
|
|
got, err := buildAzureServiceURL(tt.cloud, tt.scheme, acct, tt.endpoint)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAzureFileBackendPrefix(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prefix string
|
|
input string
|
|
expected string
|
|
}{
|
|
{name: "no prefix, plain path", prefix: "", input: "team/channel/file", expected: "team/channel/file"},
|
|
{name: "no prefix, with dot-dot", prefix: "", input: "../escape", expected: "../escape"},
|
|
{name: "prefix, plain path", prefix: "mattermost", input: "team/channel/file", expected: "mattermost/team/channel/file"},
|
|
{name: "prefix, exact root", prefix: "mattermost", input: "", expected: "mattermost"},
|
|
{name: "prefix, dot-dot escapes", prefix: "mattermost", input: "../escape", expected: "mattermost/escape"},
|
|
{name: "prefix, nested dot-dot escapes", prefix: "mattermost", input: "sub/../../escape", expected: "mattermost/escape"},
|
|
{name: "prefix, dot-dot in middle stays inside", prefix: "mattermost", input: "a/../b", expected: "mattermost/b"},
|
|
{name: "prefix with trailing slash, dot-dot escapes", prefix: "mattermost/", input: "../escape", expected: "mattermost/escape"},
|
|
{name: "prefix boundary collision must not escape", prefix: "mattermost", input: "../mattermost-evil/file", expected: "mattermost/file"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
b := &AzureFileBackend{pathPrefix: tt.prefix}
|
|
require.Equal(t, tt.expected, b.prefix(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
// azuriteWellKnownAccount and azuriteWellKnownKey are Azurite's published
|
|
// development credentials. They are not secrets - they are documented in the
|
|
// Azurite README and ship hardcoded in every Azurite distribution.
|
|
const (
|
|
azuriteWellKnownAccount = "devstoreaccount1"
|
|
azuriteWellKnownKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
|
|
)
|
|
|
|
// TestAzureFileBackendAppendRefusesNonBlockBlob exercises the safety
|
|
// check in AppendFile: when a blob exists with content but no committed
|
|
// block list (i.e. it was uploaded via Put Blob by another tool), the
|
|
// backend must refuse the append rather than silently destroy the
|
|
// existing content.
|
|
func TestAzureFileBackendAppendRefusesNonBlockBlob(t *testing.T) {
|
|
be := newAzuriteBackend(t)
|
|
|
|
path := "append-refusal-test.bin"
|
|
t.Cleanup(func() { _ = be.RemoveFile(path) })
|
|
|
|
// Write the blob via the high-level Upload helper, which calls the
|
|
// Put Blob REST endpoint and leaves the committed-block list empty.
|
|
original := []byte("planted-by-another-tool")
|
|
bb := be.newBlockBlobClient(path)
|
|
_, err := bb.Upload(context.Background(), nopReadSeekCloser{bytes.NewReader(original)}, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = be.AppendFile(bytes.NewReader([]byte("would-overwrite")), path)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "no committed block list")
|
|
|
|
// The original content must still be intact.
|
|
got, err := be.ReadFile(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, original, got)
|
|
}
|
|
|
|
// TestAzureFileBackendMakeContainerIdempotent ensures that calling
|
|
// MakeContainer twice on the same backend is a no-op the second time.
|
|
// Two nodes can race through TestConnection plus MakeContainer at boot;
|
|
// the loser must converge instead of returning an error.
|
|
func TestAzureFileBackendMakeContainerIdempotent(t *testing.T) {
|
|
be := newAzuriteBackend(t)
|
|
|
|
require.NoError(t, be.MakeContainer())
|
|
require.NoError(t, be.MakeContainer())
|
|
}
|
|
|
|
type nopReadSeekCloser struct {
|
|
*bytes.Reader
|
|
}
|
|
|
|
func (nopReadSeekCloser) Close() error { return nil }
|
|
|
|
// newAzuriteBackend builds an Azure backend pointed at the Azurite emulator
|
|
// and ensures the container exists. Standalone Azure tests should use this
|
|
// instead of calling NewAzureFileBackend + TestConnection directly; the
|
|
// shared FileBackendTestSuite handles provisioning itself in SetupTest.
|
|
func newAzuriteBackend(t *testing.T) *AzureFileBackend {
|
|
t.Helper()
|
|
be, err := NewAzureFileBackend(azuriteSettings(t))
|
|
require.NoError(t, err)
|
|
|
|
var noBucket *FileBackendNoBucketError
|
|
if err := be.TestConnection(); errors.As(err, &noBucket) {
|
|
require.NoError(t, be.MakeContainer())
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
return be
|
|
}
|
|
|
|
func azuriteSettings(t *testing.T) FileBackendSettings {
|
|
t.Helper()
|
|
host := os.Getenv("CI_AZURITE_HOST")
|
|
if host == "" {
|
|
host = "localhost"
|
|
}
|
|
port := os.Getenv("CI_AZURITE_PORT")
|
|
if port == "" {
|
|
port = "10000"
|
|
}
|
|
return FileBackendSettings{
|
|
DriverName: driverAzure,
|
|
AzureStorageAccount: azuriteWellKnownAccount,
|
|
AzureAuthMode: model.AzureAuthModeSharedKey,
|
|
AzureAccessKey: azuriteWellKnownKey,
|
|
AzureContainer: "mattermost-test",
|
|
AzureCloud: model.AzureCloudCustom,
|
|
AzureEndpoint: "http://" + net.JoinHostPort(host, port) + "/" + azuriteWellKnownAccount + "/",
|
|
// The emulator runs on an internal address, so its host must be allowed.
|
|
AllowedUntrustedInternalConnections: host,
|
|
AzureRequestTimeoutMilliseconds: 30000,
|
|
}
|
|
}
|
|
|
|
func TestNewAzureFileBackendAuthMode(t *testing.T) {
|
|
base := FileBackendSettings{
|
|
DriverName: driverAzure,
|
|
AzureStorageAccount: "anaccount",
|
|
AzureContainer: "acontainer",
|
|
AzureEndpoint: "localhost:10000",
|
|
AzureSSL: false,
|
|
AzureRequestTimeoutMilliseconds: 30000,
|
|
}
|
|
|
|
t.Run("shared_key constructs a client", func(t *testing.T) {
|
|
s := base
|
|
s.AzureAuthMode = model.AzureAuthModeSharedKey
|
|
s.AzureAccessKey = azuriteWellKnownKey
|
|
|
|
be, err := NewAzureFileBackend(s)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, be.client)
|
|
})
|
|
|
|
t.Run("default_credential constructs a client without an access key", func(t *testing.T) {
|
|
s := base
|
|
s.AzureAuthMode = model.AzureAuthModeDefaultCredential
|
|
// Intentionally no AzureAccessKey - default credential reads
|
|
// identity from the host environment, not config.
|
|
|
|
be, err := NewAzureFileBackend(s)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, be.client)
|
|
})
|
|
|
|
t.Run("empty AuthMode is rejected", func(t *testing.T) {
|
|
s := base
|
|
s.AzureAuthMode = ""
|
|
s.AzureAccessKey = azuriteWellKnownKey
|
|
|
|
_, err := NewAzureFileBackend(s)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown azure auth mode")
|
|
})
|
|
|
|
t.Run("unknown AuthMode is rejected", func(t *testing.T) {
|
|
s := base
|
|
s.AzureAuthMode = "oauth2"
|
|
s.AzureAccessKey = azuriteWellKnownKey
|
|
|
|
_, err := NewAzureFileBackend(s)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown azure auth mode")
|
|
})
|
|
}
|
|
|
|
func TestCheckMandatoryAzureFieldsAuthMode(t *testing.T) {
|
|
base := FileBackendSettings{
|
|
AzureStorageAccount: "anaccount",
|
|
AzureContainer: "acontainer",
|
|
}
|
|
|
|
t.Run("shared_key requires access key", func(t *testing.T) {
|
|
s := base
|
|
s.AzureAuthMode = model.AzureAuthModeSharedKey
|
|
s.AzureAccessKey = ""
|
|
require.Error(t, s.CheckMandatoryAzureFields())
|
|
|
|
s.AzureAccessKey = "somekey"
|
|
require.NoError(t, s.CheckMandatoryAzureFields())
|
|
})
|
|
|
|
t.Run("default_credential does not require access key", func(t *testing.T) {
|
|
s := base
|
|
s.AzureAuthMode = model.AzureAuthModeDefaultCredential
|
|
s.AzureAccessKey = ""
|
|
require.NoError(t, s.CheckMandatoryAzureFields())
|
|
})
|
|
}
|
|
|
|
func TestAzureFileBackendTestSuite(t *testing.T) {
|
|
suite.Run(t, &FileBackendTestSuite{settings: azuriteSettings(t)})
|
|
}
|
|
|
|
// TestAzureGeneratePublicLink exercises the Service SAS path against Azurite.
|
|
// The user-delegation SAS path requires Entra ID and cannot run in CI; it is
|
|
// covered by the manual verification recipe in the PR description.
|
|
func TestAzureGeneratePublicLink(t *testing.T) {
|
|
t.Run("refuses to issue a link when presign expiration is unset", func(t *testing.T) {
|
|
settings := azuriteSettings(t)
|
|
// Deliberately leave AzurePresignExpiresSeconds at zero - the
|
|
// shape an admin would hit if they enabled the export-direct-
|
|
// download feature without ever configuring the field.
|
|
be, err := NewAzureFileBackend(settings)
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = be.GeneratePublicLink("any/path")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "presign expiration is not configured")
|
|
})
|
|
|
|
t.Run("shared-key SAS round-trips through Azurite", func(t *testing.T) {
|
|
settings := azuriteSettings(t)
|
|
settings.AzurePresignExpiresSeconds = 300
|
|
be, err := NewAzureFileBackend(settings)
|
|
require.NoError(t, err)
|
|
|
|
var noBucket *FileBackendNoBucketError
|
|
if connErr := be.TestConnection(); errors.As(connErr, &noBucket) {
|
|
require.NoError(t, be.MakeContainer())
|
|
} else {
|
|
require.NoError(t, connErr)
|
|
}
|
|
|
|
path := "tests/presign-" + model.NewId() + ".bin"
|
|
payload := bytes.Repeat([]byte("mattermost"), 4096) // 40 KiB
|
|
_, writeErr := be.WriteFile(bytes.NewReader(payload), path)
|
|
require.NoError(t, writeErr)
|
|
t.Cleanup(func() { _ = be.RemoveFile(path) })
|
|
|
|
link, expiry, err := be.GeneratePublicLink(path)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, link)
|
|
require.Equal(t, 300*time.Second, expiry)
|
|
|
|
// SAS query parameters Azurite expects to see.
|
|
u, err := url.Parse(link)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, u.Query().Get("sig"))
|
|
require.Equal(t, "r", u.Query().Get("sp"))
|
|
require.Equal(t, "attachment", u.Query().Get("rscd"))
|
|
|
|
resp, err := http.Get(link)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = resp.Body.Close() })
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, "attachment", resp.Header.Get("Content-Disposition"))
|
|
|
|
got, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, payload, got)
|
|
})
|
|
|
|
t.Run("tampered SAS is rejected by the storage service", func(t *testing.T) {
|
|
settings := azuriteSettings(t)
|
|
settings.AzurePresignExpiresSeconds = 300
|
|
be, err := NewAzureFileBackend(settings)
|
|
require.NoError(t, err)
|
|
|
|
var noBucket *FileBackendNoBucketError
|
|
if connErr := be.TestConnection(); errors.As(connErr, &noBucket) {
|
|
require.NoError(t, be.MakeContainer())
|
|
} else {
|
|
require.NoError(t, connErr)
|
|
}
|
|
|
|
path := "tests/tamper-" + model.NewId() + ".bin"
|
|
_, writeErr := be.WriteFile(bytes.NewReader([]byte("contents")), path)
|
|
require.NoError(t, writeErr)
|
|
t.Cleanup(func() { _ = be.RemoveFile(path) })
|
|
|
|
link, _, err := be.GeneratePublicLink(path)
|
|
require.NoError(t, err)
|
|
|
|
// Flip the last character of the signature so the link no longer
|
|
// matches what Azurite signed.
|
|
u, err := url.Parse(link)
|
|
require.NoError(t, err)
|
|
q := u.Query()
|
|
sig := q.Get("sig")
|
|
require.NotEmpty(t, sig)
|
|
swapped := sig[:len(sig)-1] + flipBase64Char(sig[len(sig)-1])
|
|
q.Set("sig", swapped)
|
|
u.RawQuery = q.Encode()
|
|
|
|
resp, err := http.Get(u.String())
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = resp.Body.Close() })
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("unknown auth mode is rejected", func(t *testing.T) {
|
|
// Build a backend by hand so we can exercise the default-branch
|
|
// guard. Going through NewAzureFileBackend would be rejected by
|
|
// CheckMandatoryAzureFields first.
|
|
be := &AzureFileBackend{
|
|
authMode: "totally-not-real",
|
|
presignExpires: time.Minute,
|
|
}
|
|
_, _, err := be.GeneratePublicLink("any/path")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown azure auth mode")
|
|
})
|
|
}
|
|
|
|
// flipBase64Char swaps a base64 character for another in the same alphabet
|
|
// to produce a still-valid-but-wrong signature character. Used only by the
|
|
// tamper test above.
|
|
func flipBase64Char(c byte) string {
|
|
if c == 'A' {
|
|
return "B"
|
|
}
|
|
return "A"
|
|
}
|