Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/jobs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,62 @@ jobs:
yarn exec playwright test --reporter=github
yarn exec nyc report

sharing-boundary:
name: Shared downloads in standalone and embedded SILO
needs:
- compile-binary
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GOWORK: "off"
steps:
- name: Check out Console
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Check out the maintained SILO fixture
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
repository: pgsty/silo
ref: 9b4ae82a29cc2290fb5be7b551ec3d8cf7acdd99
path: .sharing-silo
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.27.1
- name: Enable Corepack
run: corepack enable
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.0.0
with:
node-version-file: .nvmrc
cache: yarn
cache-dependency-path: web-app/yarn.lock
- name: Install browser dependencies
working-directory: web-app
run: |
yarn install --immutable
yarn exec playwright install --with-deps chromium
- name: Restore the tested Console binary
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ./console
key: ${{ runner.os }}-binary-${{ github.run_id }}
- name: Build SILO embedding this Console checkout
working-directory: .sharing-silo
run: |
go mod edit -replace=github.com/minio/console="$GITHUB_WORKSPACE"
CGO_ENABLED=0 go build -mod=mod -o "$RUNNER_TEMP/sharing-silo" .
- name: Check URL and HTTP boundaries with the race detector
run: go test -race ./api -run '^TestSharedObject(URLScope|HTTPBoundary|Cancellation)$' -count=1
- name: Verify real API and browser sharing in both deployments
run: python3 hack/test-sharing-local.py --silo "$RUNNER_TEMP/sharing-silo" --console "$GITHUB_WORKSPACE/console" --output "$RUNNER_TEMP/sharing-results"
- name: Retain sharing test evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sharing-results
path: ${{ runner.temp }}/sharing-results
if-no-files-found: warn

required-matrix:
# The release gate (release.yaml) and branch protection look at this one
# job. It lists every release-gating job explicitly and fails unless each
Expand Down Expand Up @@ -1635,6 +1691,7 @@ jobs:
- cross-compile-4
- cross-compile-5
- playwright
- sharing-boundary
runs-on: ubuntu-latest
steps:
- name: Require every job to succeed
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Restricts the anonymous share proxy to object-content GETs at the configured S3 origin and rejects all redirects. Normal public, signed and versioned object downloads remain available without a new setting. Thanks to [Jiri Pejchal (@jiri-pejchal)](https://github.com/jiri-pejchal) for reporting the internal-metrics exposure in [#52](https://github.com/pgsty/silo-console/issues/52).

As of 2026-09-13, the latest published version remains
[v2.4.0](https://github.com/pgsty/silo-console/releases/tag/v2.4.0).
The changes below are on main and selected by Server main; they are not in
Expand Down
72 changes: 63 additions & 9 deletions api/public_objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
"github.com/go-openapi/swag"
"github.com/minio/console/api/operations"
"github.com/minio/console/api/operations/public"
xnet "github.com/pgsty/silo-pkg/v3/net"
"github.com/minio/minio-go/v7/pkg/s3utils"
)

func registerPublicObjectsHandlers(api *operations.ConsoleAPI) {
Expand All @@ -53,13 +53,18 @@ func getDownloadPublicObjectResponse(params public.DownloadSharedObjectParams) (
return nil, ErrorWithContext(ctx, ErrDefault, fmt.Errorf("decoded url is null"))
}

req, err := http.NewRequest(http.MethodGet, *inputURLDecoded, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, *inputURLDecoded, nil)
if err != nil {
return nil, ErrorWithContext(ctx, err)
}

// The presigned URL was generated by Console for the SILO endpoint.
// S3 authenticates signed URLs and applies anonymous policies to public
// objects. Only this download client forbids redirects; other clients keep
// their existing behavior.
clnt := GetMinIOHTTPClient(getClientIP(params.HTTPRequest))
clnt.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := clnt.Do(req)
if err != nil {
return nil, ErrorWithContext(ctx, err)
Expand All @@ -68,6 +73,10 @@ func getDownloadPublicObjectResponse(params public.DownloadSharedObjectParams) (
return middleware.ResponderFunc(func(rw http.ResponseWriter, _ runtime.Producer) {
defer resp.Body.Close()

if resp.StatusCode >= 300 && resp.StatusCode < 400 {
http.Error(rw, "Shared object download redirects are not supported", http.StatusBadGateway)
return
}
if resp.StatusCode != http.StatusOK {
http.Error(rw, resp.Status, resp.StatusCode)
return
Expand All @@ -92,26 +101,71 @@ func getDownloadPublicObjectResponse(params public.DownloadSharedObjectParams) (
}), nil
}

// decodeMinIOStringURL decodes url and validates is a MinIO url endpoint
// decodeMinIOStringURL accepts object GETs at the configured S3 origin. It
// validates without rewriting the original URL: path and query encoding are
// part of a presigned request, and object keys are not filesystem paths.
func decodeMinIOStringURL(inputURL string) (*string, error) {
decodedURL, err := base64.RawURLEncoding.DecodeString(inputURL)
if err != nil {
return nil, err
}

// Validate input URL
parsedURL, err := xnet.ParseHTTPURL(string(decodedURL))
parsedURL, err := url.Parse(string(decodedURL))
if err != nil {
return nil, err
}
// Ensure incoming url points to MinIO Server
minIOHost := getMinIOEndpoint()
if parsedURL.Host != minIOHost {
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("unexpected scheme found %s", parsedURL.Scheme)
}
endpoint, err := url.Parse(getMinIOServer())
if err != nil {
return nil, err
}
if parsedURL.User != nil || parsedURL.Fragment != "" || parsedURL.Opaque != "" ||
parsedURL.Host == "" || parsedURL.Scheme != endpoint.Scheme ||
!strings.EqualFold(parsedURL.Hostname(), endpoint.Hostname()) ||
shareURLPort(parsedURL) != shareURLPort(endpoint) {
return nil, ErrForbidden
}

bucket, object := url2BucketAndObject(parsedURL)
if !strings.HasPrefix(parsedURL.Path, "/") || bucket == "minio" ||
strings.HasPrefix(bucket, ".minio.sys") || object == "" ||
s3utils.CheckValidBucketNameStrict(bucket) != nil {
return nil, ErrForbidden
}
// Match SILO's rejection of dot components, including encoded components
// and its treatment of backslashes/whitespace. Never clean or double-decode.
for segment := range strings.FieldsFuncSeq(parsedURL.Path, func(r rune) bool { return r == '/' || r == '\\' }) {
if segment = strings.TrimSpace(segment); segment == "." || segment == ".." {
return nil, ErrForbidden
}
}
query, err := url.ParseQuery(parsedURL.RawQuery)
if err != nil {
return nil, ErrForbidden
}
// These keys select other GET operations in SILO's object router. Keep in
// sync when adding object APIs; unknown non-routing parameters still pass.
for key := range query {
switch key {
case "acl", "tagging", "retention", "legal-hold", "attributes", "uploadId", "lambdaArn", "torrent":
return nil, ErrForbidden
}
}
return swag.String(string(decodedURL)), nil
}

func shareURLPort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
if u.Scheme == "https" {
return "443"
}
return "80"
}

func url2BucketAndObject(u *url.URL) (bucketName, objectName string) {
tokens := splitStr(u.Path, "/", 3)
return tokens[1], tokens[2]
Expand Down
130 changes: 130 additions & 0 deletions api/public_objects_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Pigsty
// SPDX-License-Identifier: AGPL-3.0-or-later

package api

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strings"
"testing"
"time"

"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/stretchr/testify/require"
)

// Run against disposable SILO + standalone or embedded Console, never production.
// The test creates and removes only its uniquely named, versioned bucket.
func TestSharedObjectLiveStack(t *testing.T) {
endpoint, console := os.Getenv("SILO_SHARE_TEST_ENDPOINT"), os.Getenv("CONSOLE_SHARE_TEST_ENDPOINT")
if endpoint == "" || console == "" {
t.Skip("set SILO_SHARE_TEST_ENDPOINT and CONSOLE_SHARE_TEST_ENDPOINT for a disposable live stack")
}
accessKey, secretKey := os.Getenv("SILO_SHARE_TEST_ACCESS_KEY"), os.Getenv("SILO_SHARE_TEST_SECRET_KEY")
require.NotEmpty(t, accessKey)
require.NotEmpty(t, secretKey)
u, err := url.Parse(endpoint)
require.NoError(t, err)
s3, err := minio.New(u.Host, &minio.Options{Secure: u.Scheme == "https", Creds: credentials.NewStaticV4(accessKey, secretKey, "")})
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
bucket := fmt.Sprintf("share-probe-%d", time.Now().UnixNano())
require.NoError(t, s3.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}))
t.Cleanup(func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
for object := range s3.ListObjects(cleanupCtx, bucket, minio.ListObjectsOptions{Recursive: true, WithVersions: true}) {
require.NoError(t, object.Err)
require.NoError(t, s3.RemoveObject(cleanupCtx, bucket, object.Key, minio.RemoveObjectOptions{VersionID: object.VersionID}))
}
require.NoError(t, s3.RemoveBucket(cleanupCtx, bucket))
})
require.NoError(t, s3.SetBucketVersioning(ctx, bucket, minio.BucketVersioningConfiguration{Status: "Enabled"}))
require.NoError(t, s3.SetBucketPolicy(ctx, bucket, fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/public/*"]}]}`, bucket)))
put := func(key, body string) minio.UploadInfo {
t.Helper()
info, err := s3.PutObject(ctx, bucket, key, strings.NewReader(body), int64(len(body)), minio.PutObjectOptions{ContentType: "text/plain"})
require.NoError(t, err)
return info
}
put("public/hello.txt", "public body")
old := put("private.txt", "old version")
put("private.txt", "current version")
encodedKey := "folder/中文 +%?#.txt"
put(encodedKey, "encoded body")
sign := func(key string, query url.Values) *url.URL {
t.Helper()
u, err := s3.PresignedGetObject(ctx, bucket, key, 5*time.Minute, query)
require.NoError(t, err)
return u
}
anon := &http.Client{Timeout: 10 * time.Second}
request := func(client *http.Client, method, target string, body []byte, status int) []byte {
t.Helper()
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
require.NoError(t, err)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, status, resp.StatusCode, "response: %s", data)
return data
}
// Restricting the Console proxy does not change access to the S3 listener.
publicURL := endpoint + "/" + bucket + "/public/hello.txt"
require.Equal(t, "public body", string(request(anon, "GET", publicURL, nil, 200)))
metrics := request(anon, "GET", endpoint+"/minio/v2/metrics/cluster", nil, 200)
require.Contains(t, string(metrics), "minio_")
for _, target := range []string{
endpoint + "/minio/v2/metrics/cluster", endpoint + "/minio/v2/metrics/cluster?X-Amz-Signature=fake",
endpoint + "/minio/metrics/v3/cluster/usage/buckets",
endpoint + "/minio/health/live", endpoint + "/minio/admin/v3/info", endpoint + "/" + bucket + "?list-type=2",
endpoint + "/" + bucket + "/public/hello.txt?tagging",
} {
request(anon, "GET", console+sharedURL(target), nil, 403)
}
t.Log("public metrics remain directly readable but all non-object proxy requests return 403")
for _, tc := range []struct{ target, body string }{
{publicURL, "public body"},
{sign("private.txt", nil).String(), "current version"},
{sign("private.txt", url.Values{"versionId": {old.VersionID}}).String(), "old version"},
{sign(encodedKey, nil).String(), "encoded body"},
} {
require.Equal(t, tc.body, string(request(anon, "GET", console+sharedURL(tc.target), nil, 200)))
}
request(anon, "GET", console+sharedURL(endpoint+"/"+bucket+"/private.txt"), nil, 403)
badSignature := sign("private.txt", nil)
query := badSignature.Query()
query.Set("X-Amz-Signature", strings.Repeat("0", 64))
badSignature.RawQuery = query.Encode()
request(anon, "GET", console+sharedURL(badSignature.String()), nil, 403)
jar, err := cookiejar.New(nil)
require.NoError(t, err)
login := &http.Client{Jar: jar, Timeout: 10 * time.Second}
loginBody, err := json.Marshal(map[string]string{"accessKey": accessKey, "secretKey": secretKey})
require.NoError(t, err)
request(login, "POST", console+"/api/v1/login", loginBody, 204)
for _, toggle := range []string{"false", "true"} {
query := url.Values{"prefix": {"private.txt"}, "version_id": {old.VersionID}, "expires": {"5m"}, "toggle_url": {toggle}}
creation := console + "/api/v1/buckets/" + bucket + "/objects/share?" + query.Encode()
response := request(login, "GET", creation, nil, 200)
var link string
require.NoError(t, json.Unmarshal(response, &link))
require.Equal(t, "old version", string(request(anon, "GET", link, nil, 200)))
}
t.Log("public/private/versioned/encoded objects, S3 denials, login and both link formats verified without extra configuration")
}
Loading