Files
minio/cmd/common-main_test.go
Feng Ruohang 711b092f86 fix: keep embedded Console login working over loopback TLS (#108)
The embedded Console reaches the S3/STS API at https://127.0.0.1:<port>
(minioConfigToConsoleFeatures), a loopback endpoint whose TLS certificate is
not expected to carry a 127.0.0.1 SAN. silo-console v2.3.x began verifying
every outbound TLS peer, so the Console's STS AssumeRole handshake to that
loopback endpoint now fails certificate validation and BOTH local and LDAP
logins fail with a generic "invalid login". The failure happens in the Console
HTTP client before any request reaches a server auth/STS/LDAP handler, so no
server-side auth error is logged, matching the report.

Restore the documented loopback bypass by opting the embedded Console into its
endpoint-scoped CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY switch whenever the server
falls back to the 127.0.0.1 endpoint under TLS. The exemption is scoped to that
single loopback origin inside Console; every other HTTPS peer (IdP, Prometheus,
webhooks) stays verified, preserving the v2.3.x hardening. An explicitly
configured endpoint is reached under its own verified name and is never
exempted. initConsoleServer unsets CONSOLE_* before re-deriving them, so the
switch cannot be supplied by the operator on the embedded path; the server must
assert it.

Fixes #108

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7qJqWwy8oFA6aCXWRzXQe
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-09-07 10:32:59 +08:00

428 lines
11 KiB
Go

// Copyright (c) 2015-2021 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"errors"
"fmt"
"os"
"reflect"
"slices"
"strings"
"testing"
"github.com/minio/minio/internal/config"
)
func Test_readFromSecret(t *testing.T) {
testCases := []struct {
content string
expectedErr bool
expectedValue string
}{
{
"value\n",
false,
"value",
},
{
" \t\n Hello, Gophers \n\t\r\n",
false,
"Hello, Gophers",
},
}
for _, testCase := range testCases {
t.Run("", func(t *testing.T) {
tmpfile, err := os.CreateTemp(t.TempDir(), "testfile")
if err != nil {
t.Error(err)
}
tmpfile.WriteString(testCase.content)
tmpfile.Sync()
tmpfile.Close()
value, err := readFromSecret(tmpfile.Name())
if err != nil && !testCase.expectedErr {
t.Error(err)
}
if err == nil && testCase.expectedErr {
t.Error(errors.New("expected error, found success"))
}
if value != testCase.expectedValue {
t.Errorf("Expected %s, got %s", testCase.expectedValue, value)
}
})
}
}
func Test_minioEnvironFromFile(t *testing.T) {
testCases := []struct {
content string
expectedErr bool
expectedEkvs []envKV
}{
{
`
export MINIO_ROOT_USER=minio
export MINIO_ROOT_PASSWORD=minio123`,
false,
[]envKV{
{
Key: "MINIO_ROOT_USER",
Value: "minio",
},
{
Key: "MINIO_ROOT_PASSWORD",
Value: "minio123",
},
},
},
// Value with double quotes
{
`export MINIO_ROOT_USER="minio"`,
false,
[]envKV{
{
Key: "MINIO_ROOT_USER",
Value: "minio",
},
},
},
// Value with single quotes
{
`export MINIO_ROOT_USER='minio'`,
false,
[]envKV{
{
Key: "MINIO_ROOT_USER",
Value: "minio",
},
},
},
{
`
MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=minio123`,
false,
[]envKV{
{
Key: "MINIO_ROOT_USER",
Value: "minio",
},
{
Key: "MINIO_ROOT_PASSWORD",
Value: "minio123",
},
},
},
{
`
export MINIO_ROOT_USERminio
export MINIO_ROOT_PASSWORD=minio123`,
true,
nil,
},
{
`
# simple comment
# MINIO_ROOT_USER=minioadmin
# MINIO_ROOT_PASSWORD=minioadmin
MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=minio123`,
false,
[]envKV{
{
Key: "MINIO_ROOT_USER",
Value: "minio",
},
{
Key: "MINIO_ROOT_PASSWORD",
Value: "minio123",
},
},
},
}
for _, testCase := range testCases {
t.Run("", func(t *testing.T) {
tmpfile, err := os.CreateTemp(t.TempDir(), "testfile")
if err != nil {
t.Error(err)
}
tmpfile.WriteString(testCase.content)
tmpfile.Sync()
tmpfile.Close()
ekvs, err := minioEnvironFromFile(tmpfile.Name())
if err != nil && !testCase.expectedErr {
t.Error(err)
}
if err == nil && testCase.expectedErr {
t.Error(errors.New("expected error, found success"))
}
if len(ekvs) != len(testCase.expectedEkvs) {
t.Errorf("expected %v keys, got %v keys", len(testCase.expectedEkvs), len(ekvs))
}
if !reflect.DeepEqual(ekvs, testCase.expectedEkvs) {
t.Errorf("expected %v, got %v", testCase.expectedEkvs, ekvs)
}
})
}
}
func Test_minioEnvironFromFileWhitespaceAndValidation(t *testing.T) {
testCases := []struct {
name string
content string
want []envKV
errLine int
errContains string
errExcludes string
}{
{
name: "spaces and tabs around separator",
content: "MINIO_ROOT_USER = minio\nMINIO_ROOT_PASSWORD\t=\tminio123",
want: []envKV{
{Key: "MINIO_ROOT_USER", Value: "minio"},
{Key: "MINIO_ROOT_PASSWORD", Value: "minio123"},
},
},
{
name: "export tab and quoted spaces",
content: "export\tMINIO_ROOT_USER = \" minio user \"\nexport MINIO_ROOT_PASSWORD = ' minio secret '",
want: []envKV{
{Key: "MINIO_ROOT_USER", Value: " minio user "},
{Key: "MINIO_ROOT_PASSWORD", Value: " minio secret "},
},
},
{
name: "export Unicode whitespace",
content: "export\u00a0MINIO_ROOT_USER=value",
want: []envKV{
{Key: "MINIO_ROOT_USER", Value: "value"},
},
},
{
name: "export is only a standalone prefix",
content: "export=value\nexportFOO=bar",
want: []envKV{
{Key: "export", Value: "value"},
{Key: "exportFOO", Value: "bar"},
},
},
{
name: "unquoted whitespace empty value and additional separators",
content: "UNQUOTED = value \nEMPTY =\nTOKEN = scheme://user:password@example.com?a=b",
want: []envKV{
{Key: "UNQUOTED", Value: "value"},
{Key: "EMPTY", Value: ""},
{Key: "TOKEN", Value: "scheme://user:password@example.com?a=b"},
},
},
{
name: "valid underscore and digits",
content: "_VALID_2=value",
want: []envKV{
{Key: "_VALID_2", Value: "value"},
},
},
{
name: "named target punctuation and unicode",
content: "MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook=off\n" +
"MINIO_NOTIFY_WEBHOOK_ENABLE_site.eu=off\n" +
"MINIO_NOTIFY_WEBHOOK_ENABLE_team:blue=off\n" +
"MINIO_NOTIFY_WEBHOOK_ENABLE_目标=off",
want: []envKV{
{Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook", Value: "off"},
{Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_site.eu", Value: "off"},
{Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_team:blue", Value: "off"},
{Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_目标", Value: "off"},
},
},
{
name: "missing separator redacts the line",
content: "MINIO_ROOT_PASSWORD=valid\nsuper-secret-without-equals",
errLine: 2,
errContains: "missing '='",
errExcludes: "super-secret-without-equals",
},
{
name: "empty name",
content: "=empty-name-secret",
errLine: 1,
errContains: `invalid environment variable name ""`,
errExcludes: "empty-name-secret",
},
{
name: "os compatible leading digit and punctuation",
content: "1MINIO_ROOT_USER=digit-leading-secret\n-MINIO-ROOT-USER=hyphen-secret",
want: []envKV{
{Key: "1MINIO_ROOT_USER", Value: "digit-leading-secret"},
{Key: "-MINIO-ROOT-USER", Value: "hyphen-secret"},
},
},
{
name: "whitespace in name",
content: "MINIO ROOT USER=whitespace-secret",
errLine: 1,
errContains: `invalid environment variable name "MINIO ROOT USER"`,
errExcludes: "whitespace-secret",
},
{
name: "NUL in name",
content: "MINIO\x00ROOT=nul-name-secret",
errLine: 1,
errContains: "invalid environment variable name",
errExcludes: "nul-name-secret",
},
{
name: "format character in name",
content: "MINIO\u200bROOT=format-secret",
errLine: 1,
errContains: "invalid environment variable name",
errExcludes: "format-secret",
},
{
name: "NUL in value",
content: "MINIO_ROOT_USER=before\x00nul-value-secret",
errLine: 1,
errContains: "environment variable value contains NUL",
errExcludes: "nul-value-secret",
},
{
name: "diagnostic has file and line but no value",
content: "MINIO_ROOT_USER=valid\nBAD KEY=super-secret-value",
errLine: 2,
errContains: `invalid environment variable name "BAD KEY"`,
errExcludes: "super-secret-value",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tmpfile, err := os.CreateTemp(t.TempDir(), "testfile")
if err != nil {
t.Fatal(err)
}
if _, err = tmpfile.WriteString(testCase.content); err != nil {
t.Fatal(err)
}
if err = tmpfile.Close(); err != nil {
t.Fatal(err)
}
got, err := minioEnvironFromFile(tmpfile.Name())
if testCase.errContains == "" {
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, testCase.want) {
t.Errorf("expected %v, got %v", testCase.want, got)
}
return
}
if err == nil {
t.Fatal("expected an error")
}
errText := err.Error()
location := fmt.Sprintf("%s:%d:", tmpfile.Name(), testCase.errLine)
if !strings.Contains(errText, location) {
t.Errorf("expected error to contain %q, got %q", location, errText)
}
if !strings.Contains(errText, testCase.errContains) {
t.Errorf("expected error to contain %q, got %q", testCase.errContains, errText)
}
if testCase.errExcludes != "" && strings.Contains(errText, testCase.errExcludes) {
t.Errorf("expected error to redact %q, got %q", testCase.errExcludes, errText)
}
if got != nil {
t.Errorf("expected no entries on parse error, got %v", got)
}
})
}
}
func TestConfigEnvFileNamedTargetDiscovery(t *testing.T) {
key := "MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook"
t.Setenv(key, "off")
targets, err := (config.Config{}).GetAvailableTargets(config.NotifyWebhookSubSys)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(targets, "my-hook") {
t.Fatalf("named target %q not discovered from %s: %v", "my-hook", key, targets)
}
}
// TestConsoleMinIOServerEnv locks in the loopback TLS exemption that keeps
// embedded Console login working (issue #108) while ensuring an explicitly
// configured endpoint is never silently exempted from TLS verification.
func TestConsoleMinIOServerEnv(t *testing.T) {
tests := []struct {
name string
endpoint string
isTLS bool
port string
wantServer string
wantSkipVerify bool
}{
{
name: "loopback TLS is exempted so embedded login works",
isTLS: true,
port: "9000",
wantServer: "https://127.0.0.1:9000",
wantSkipVerify: true,
},
{
name: "loopback plain HTTP needs no exemption",
isTLS: false,
port: "9000",
wantServer: "http://127.0.0.1:9000",
},
{
name: "explicit https endpoint stays verified",
endpoint: "https://silo.example:9000",
isTLS: true,
port: "9000",
wantServer: "https://silo.example:9000",
},
{
name: "explicit http endpoint stays verified",
endpoint: "http://silo.example:9000",
isTLS: false,
port: "9000",
wantServer: "http://silo.example:9000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, skipVerify := consoleMinIOServerEnv(tt.endpoint, tt.isTLS, tt.port)
if server != tt.wantServer {
t.Fatalf("server = %q, want %q", server, tt.wantServer)
}
if skipVerify != tt.wantSkipVerify {
t.Fatalf("skipVerify = %v, want %v", skipVerify, tt.wantSkipVerify)
}
})
}
}