Files
team/server/platform/shared/filestore/azurestore_test.go
Alejandro García MontoroandGitHub 82106eb77e MM-69213: Validate Azure storage account names and use httpservice for custom endpoints (#37014)
* 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.
2026-06-15 15:46:42 +02:00

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"
}