diff --git a/.github/workflows/website-hosting-tests.yml b/.github/workflows/website-hosting-tests.yml new file mode 100644 index 00000000..448d1153 --- /dev/null +++ b/.github/workflows/website-hosting-tests.yml @@ -0,0 +1,13 @@ +name: website hosting tests +permissions: {} +on: pull_request + +jobs: + build-and-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: run website hosting tests + run: make test-website-hosting diff --git a/Makefile b/Makefile index 6da6d45f..0bf43207 100644 --- a/Makefile +++ b/Makefile @@ -107,3 +107,12 @@ test-host-style: COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \ exit $$status +# Run the static website hosting tests in docker containers +.PHONY: test-website-hosting +test-website-hosting: + @compose_file=tests/website-hosting-tests/docker-compose.yml; \ + COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans >/dev/null 2>&1 || true; \ + COMPOSE_MENU=false docker compose -f "$$compose_file" up --build --abort-on-container-exit --exit-code-from test; \ + status=$$?; \ + COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \ + exit $$status diff --git a/backend/azure/azure.go b/backend/azure/azure.go index ad411f11..ea5439f7 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -63,6 +63,7 @@ const ( keyTags key = "Tags" keyPolicy key = "Policy" keyCors key = "Cors" + keyWebsite key = "Website" keyBucketLock key = "Bucketlock" keyObjRetention key = "Objectretention" keyObjLegalHold key = "Objectlegalhold" @@ -88,6 +89,7 @@ func (key) Table() map[string]struct{} { "tags": {}, "policy": {}, "bucketlock": {}, + "website": {}, "objectretention": {}, "vgwexpires": {}, "objectlegalhold": {}, @@ -1994,6 +1996,40 @@ func (az *Azure) DeleteBucketCors(ctx context.Context, bucket string) error { return az.PutBucketCors(ctx, bucket, nil) } +func (az *Azure) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error { + if website == nil { + return az.deleteContainerMetaData(ctx, bucket, string(keyWebsite)) + } + + encoded, err := backend.MarshalWebsiteConfig(website, true) + if err != nil { + return err + } + + return az.setContainerMetaData(ctx, bucket, string(keyWebsite), encoded) +} + +func (az *Azure) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) { + website, err := az.getContainerMetaData(ctx, bucket, string(keyWebsite)) + if err != nil { + return nil, err + } + if len(website) == 0 { + return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket) + } + + decoded, err := backend.UnmarshalWebsiteConfig(website, true) + if err != nil { + return nil, err + } + + return decoded, nil +} + +func (az *Azure) DeleteBucketWebsite(ctx context.Context, bucket string) error { + return az.PutBucketWebsite(ctx, bucket, nil) +} + func (az *Azure) PutObjectLockConfiguration(ctx context.Context, bucket string, config []byte) error { return az.setContainerMetaData(ctx, bucket, string(keyBucketLock), config) } diff --git a/backend/common.go b/backend/common.go index baa22307..536f7a4d 100644 --- a/backend/common.go +++ b/backend/common.go @@ -436,7 +436,7 @@ func MarshalMpUploadMetadata(mpMeta MpUploadMetadata, base64Encode bool) ([]byte return nil, fmt.Errorf("marshal mp metadata: %w", err) } - compressed, err := compressMpUploadMetadata(mpMetaJSON) + compressed, err := CompressData(mpMetaJSON) if err != nil { return nil, fmt.Errorf("compress mp metadata: %w", err) } @@ -471,7 +471,43 @@ func UnmarshalMpUploadMetadata(data []byte, base64Decode bool) (MpUploadMetadata return mpMeta, nil } -func compressMpUploadMetadata(data []byte) ([]byte, error) { +// MarshalWebsiteConfig returns a compressed representation of a website +// configuration. When base64Encode is true, the compressed bytes are +// base64-encoded so they can be stored in azure string-only metadata values. +func MarshalWebsiteConfig(website []byte, base64Encode bool) ([]byte, error) { + compressed, err := CompressData(website) + if err != nil { + return nil, fmt.Errorf("compress website config: %w", err) + } + + if !base64Encode { + return compressed, nil + } + + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(compressed))) + base64.StdEncoding.Encode(encoded, compressed) + return encoded, nil +} + +// UnmarshalWebsiteConfig decodes data produced by MarshalWebsiteConfig. +func UnmarshalWebsiteConfig(data []byte, base64Decode bool) ([]byte, error) { + if base64Decode { + compressed, err := base64.StdEncoding.DecodeString(string(data)) + if err != nil { + return nil, fmt.Errorf("decode website config: %w", err) + } + data = compressed + } + + website, err := DecompressData(data) + if err != nil { + return nil, fmt.Errorf("decompress website config: %w", err) + } + + return website, nil +} + +func CompressData(data []byte) ([]byte, error) { var compressed bytes.Buffer gz := gzip.NewWriter(&compressed) if _, err := gz.Write(data); err != nil { @@ -484,19 +520,28 @@ func compressMpUploadMetadata(data []byte) ([]byte, error) { return compressed.Bytes(), nil } -func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) { - var mpMeta MpUploadMetadata - gz, err := gzip.NewReader(bytes.NewReader(compressed)) +func DecompressData(data []byte) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { - return mpMeta, fmt.Errorf("decompress mp metadata: %w", err) + return nil, err } decompressed, err := io.ReadAll(gz) closeErr := gz.Close() if err != nil { - return mpMeta, fmt.Errorf("decompress mp metadata: %w", err) + return nil, err } if closeErr != nil { - return mpMeta, fmt.Errorf("decompress mp metadata: %w", closeErr) + return nil, err + } + + return decompressed, nil +} + +func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) { + var mpMeta MpUploadMetadata + decompressed, err := DecompressData(compressed) + if err != nil { + return mpMeta, fmt.Errorf("decompress mp metadata: %w", err) } if err := json.Unmarshal(decompressed, &mpMeta); err != nil { diff --git a/backend/common_test.go b/backend/common_test.go index fa69eb66..a0307471 100644 --- a/backend/common_test.go +++ b/backend/common_test.go @@ -107,6 +107,53 @@ func TestUnmarshalMpUploadMetadataInvalid(t *testing.T) { } } +func TestWebsiteConfigRawGzipRoundTrip(t *testing.T) { + want := []byte(`index.html`) + + stored, err := MarshalWebsiteConfig(want, false) + if err != nil { + t.Fatalf("MarshalWebsiteConfig: %v", err) + } + if len(stored) < 2 || stored[0] != 0x1f || stored[1] != 0x8b { + t.Fatalf("stored website config should contain raw gzip payload: %q", stored) + } + + got, err := UnmarshalWebsiteConfig(stored, false) + if err != nil { + t.Fatalf("UnmarshalWebsiteConfig: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("website config mismatch: got %q want %q", got, want) + } +} + +func TestWebsiteConfigBase64RoundTrip(t *testing.T) { + want := []byte(`example.com`) + + stored, err := MarshalWebsiteConfig(want, true) + if err != nil { + t.Fatalf("MarshalWebsiteConfig: %v", err) + } + if len(stored) >= 2 && stored[0] == 0x1f && stored[1] == 0x8b { + t.Fatalf("stored website config should not contain raw gzip bytes: %q", stored) + } + + got, err := UnmarshalWebsiteConfig(stored, true) + if err != nil { + t.Fatalf("UnmarshalWebsiteConfig: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("website config mismatch: got %q want %q", got, want) + } +} + +func TestUnmarshalWebsiteConfigInvalid(t *testing.T) { + _, err := UnmarshalWebsiteConfig([]byte("not-gzip"), false) + if err == nil { + t.Fatal("expected invalid website config error") + } +} + func TestParseCopySource(t *testing.T) { tests := []struct { name string diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 1e34ff0d..8494ec64 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -6196,7 +6196,14 @@ func (p *Posix) PutBucketWebsite(ctx context.Context, bucket string, website []b return nil } - err = p.meta.StoreAttribute(nil, bucket, "", websitekey, website) + // The website configuration can be up to 128KB + // compress the data to fit in 64KB xattr limits + encoded, err := backend.MarshalWebsiteConfig(website, false) + if err != nil { + return err + } + + err = p.meta.StoreAttribute(nil, bucket, "", websitekey, encoded) if err != nil { return fmt.Errorf("set website: %w", err) } @@ -6224,13 +6231,18 @@ func (p *Posix) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, er website, err := p.meta.RetrieveAttribute(nil, bucket, "", websitekey) if errors.Is(err, meta.ErrNoSuchKey) { - return nil, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration) + return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket) } if err != nil { return nil, err } - return website, nil + decoded, err := backend.UnmarshalWebsiteConfig(website, false) + if err != nil { + return nil, err + } + + return decoded, nil } func (p *Posix) DeleteBucketWebsite(ctx context.Context, bucket string) error { diff --git a/backend/s3proxy/s3.go b/backend/s3proxy/s3.go index cb662055..ce3fbb0d 100644 --- a/backend/s3proxy/s3.go +++ b/backend/s3proxy/s3.go @@ -1759,7 +1759,7 @@ func (s *S3Proxy) putMetaBucketObj(ctx context.Context, bucket string, data []by func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefix metaPrefix, checkExists bool) ([]byte, error) { // return default bahviour of get bucket policy/acl, if meta bucket is not provided if s.metaBucket == "" { - return handleMetaBucketObjectNotFoundErr(prefix) + return handleMetaBucketObjectNotFoundErr(bucket, prefix) } key := getMetaKey(bucket, prefix) @@ -1773,7 +1773,7 @@ func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefi return nil, err } - return handleMetaBucketObjectNotFoundErr(prefix) + return handleMetaBucketObjectNotFoundErr(bucket, prefix) } if err != nil { return nil, err @@ -1790,17 +1790,17 @@ func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefi // handles the case when an object with the given metprefix // is not found in meta bucket. Aggregates the not found errors // for each meta prefix -func handleMetaBucketObjectNotFoundErr(prefix metaPrefix) ([]byte, error) { +func handleMetaBucketObjectNotFoundErr(bucket string, prefix metaPrefix) ([]byte, error) { switch prefix { case metaPrefixAcl: // If bucket acl is not found, return default acl return []byte{}, nil case metaPrefixPolicy: - return nil, s3err.GetBucketErr(s3err.ErrNoSuchBucketPolicy, "") + return nil, s3err.GetBucketErr(s3err.ErrNoSuchBucketPolicy, bucket) case metaPrefixCors: - return nil, s3err.GetBucketErr(s3err.ErrNoSuchCORSConfiguration, "") + return nil, s3err.GetBucketErr(s3err.ErrNoSuchCORSConfiguration, bucket) case metaPrefixWebsite: - return nil, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration) + return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket) } return []byte{}, nil diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index c1f62ce6..bed7ab0f 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -914,6 +914,11 @@ func runGateway(ctx context.Context, be backend.Backend) error { WebuiAdminGateways: webuiAdminGateways, WebuiPathPrefix: webuiPathPrefix, WebuiS3Prefix: webuiS3Prefix, + WebsitePorts: websitePorts, + WebsiteDomain: websiteDomain, + WebsiteCertFile: websiteCertFile, + WebsiteKeyFile: websiteKeyFile, + WebsiteNoTLS: websiteNoTLS, SigHup: sigHup, Version: Version, Build: Build, diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 3f33d680..9bdffd8e 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -16,32 +16,35 @@ package main import ( "fmt" + "strings" "github.com/urfave/cli/v2" "github.com/versity/versitygw/tests/integration" ) var ( - awsID string - awsSecret string - endpoint string - websiteEndpointTest string - prefix string - dstBucket string - partSize int64 - objSize int64 - concurrency int - files int - totalReqs int - upload bool - download bool - hostStyle bool - checksumDisable bool - versioningEnabled bool - azureTests bool - tlsStatus bool - parallel bool - sidecarTests bool + awsID string + awsSecret string + endpoint string + websiteSchemeTest string + websiteDomainTest string + websitePortTest string + prefix string + dstBucket string + partSize int64 + objSize int64 + concurrency int + files int + totalReqs int + upload bool + download bool + hostStyle bool + checksumDisable bool + versioningEnabled bool + azureTests bool + tlsStatus bool + parallel bool + sidecarTests bool ) func testCommand() *cli.Command { @@ -77,13 +80,6 @@ func initTestFlags() []cli.Flag { Destination: &endpoint, Aliases: []string{"e"}, }, - &cli.StringFlag{ - Name: "website-endpoint", - Usage: "dedicated website hosting endpoint (e.g. 'http://localhost:8080'); required for WebsiteHosting tests", - EnvVars: []string{"VGW_TEST_WEBSITE_ENDPOINT"}, - Destination: &websiteEndpointTest, - Aliases: []string{"we"}, - }, &cli.BoolFlag{ Name: "host-style", Usage: "Use host-style bucket addressing", @@ -139,6 +135,36 @@ func initTestCommands() []*cli.Command { }, }, }, + { + Name: "website-hosting", + Usage: "Tests static website hosting endpoint.", + Description: `Runs the static website hosting integration tests against a dedicated website endpoint.`, + Action: websiteHostingAction, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "scheme", + Usage: "website endpoint scheme: http or https", + EnvVars: []string{"VGW_TEST_WEBSITE_SCHEME"}, + Destination: &websiteSchemeTest, + Aliases: []string{"website-scheme", "protocol"}, + Value: "http", + }, + &cli.StringFlag{ + Name: "domain", + Usage: "website endpoint base domain used for virtual-host routing", + EnvVars: []string{"VGW_TEST_WEBSITE_DOMAIN"}, + Destination: &websiteDomainTest, + Aliases: []string{"website-domain"}, + }, + &cli.StringFlag{ + Name: "port", + Usage: "website endpoint port", + EnvVars: []string{"VGW_TEST_WEBSITE_PORT"}, + Destination: &websitePortTest, + Aliases: []string{"website-port"}, + }, + }, + }, { Name: "posix", Usage: "Tests posix specific features", @@ -333,6 +359,50 @@ func initTestCommands() []*cli.Command { type testFunc func(*integration.TestState) +func websiteHostingAction(ctx *cli.Context) error { + websiteSchemeTest = strings.ToLower(strings.TrimSpace(websiteSchemeTest)) + if websiteSchemeTest != "http" && websiteSchemeTest != "https" { + return fmt.Errorf("website scheme must be http or https") + } + if websiteDomainTest == "" { + return fmt.Errorf("must specify website domain") + } + if websitePortTest == "" { + return fmt.Errorf("must specify website port") + } + + opts := []integration.Option{ + integration.WithAccess(awsID), + integration.WithSecret(awsSecret), + integration.WithRegion(region), + integration.WithEndpoint(endpoint), + } + if websiteSchemeTest != "" { + opts = append(opts, integration.WithWebsiteScheme(websiteSchemeTest)) + } + if websiteDomainTest != "" { + opts = append(opts, integration.WithWebsiteDomain(websiteDomainTest)) + } + if websitePortTest != "" { + opts = append(opts, integration.WithWebsitePort(websitePortTest)) + } + if debug { + opts = append(opts, integration.WithDebug()) + } + + s := integration.NewS3Conf(opts...) + ts := integration.NewTestState(ctx.Context, s, false) + integration.TestWebsiteHosting(ts) + ts.Wait() + + fmt.Println() + fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load()) + if integration.FailCount.Load() > 0 { + return fmt.Errorf("test failed with %v errors", integration.FailCount.Load()) + } + return nil +} + func getAction(tf testFunc) func(ctx *cli.Context) error { return func(ctx *cli.Context) error { opts := []integration.Option{ @@ -342,9 +412,6 @@ func getAction(tf testFunc) func(ctx *cli.Context) error { integration.WithEndpoint(endpoint), integration.WithTLSStatus(tlsStatus), } - if websiteEndpointTest != "" { - opts = append(opts, integration.WithWebsiteEndpoint(websiteEndpointTest)) - } if debug { opts = append(opts, integration.WithDebug()) } @@ -391,9 +458,6 @@ func extractIntTests() (commands []*cli.Command) { integration.WithEndpoint(endpoint), integration.WithTLSStatus(tlsStatus), } - if websiteEndpointTest != "" { - opts = append(opts, integration.WithWebsiteEndpoint(websiteEndpointTest)) - } if debug { opts = append(opts, integration.WithDebug()) } diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index 524700ea..ae01cbeb 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -42,6 +42,7 @@ import ( "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3event" "github.com/versity/versitygw/s3log" + "github.com/versity/versitygw/website" "github.com/versity/versitygw/webui" ) @@ -423,6 +424,29 @@ type Config struct { // endpoint. WebuiS3Prefix string + // Static website hosting endpoint + // + // WebsitePorts is the list of listening addresses for the static website + // hosting endpoint. Accepts the same formats as Ports. When empty, the + // website endpoint is disabled. + WebsitePorts []string + // WebsiteDomain is the base domain for website virtual-host routing. For + // example, host "blog.example.com" serves bucket "blog" when this is + // "example.com". When empty, the full request hostname is used as the + // bucket name. + WebsiteDomain string + // WebsiteCertFile is the path to the TLS certificate for the website + // endpoint. When empty and gateway TLS (CertFile/KeyFile) is configured, + // the website endpoint inherits those certs. Both WebsiteCertFile and + // WebsiteKeyFile must be provided together. + WebsiteCertFile string + // WebsiteKeyFile is the path to the TLS private key for the website + // endpoint. + WebsiteKeyFile string + // WebsiteNoTLS forces the website endpoint to use plain HTTP even when TLS + // certificates are available. + WebsiteNoTLS bool + // SigHup is an optional channel that signals the gateway to reload TLS // certificates and rotate log files (equivalent to SIGHUP). When nil, // this feature is disabled. @@ -514,7 +538,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; %s\n", suggestion) } - if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts); err != nil { + if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts, cfg.WebsitePorts); err != nil { return err } @@ -882,6 +906,60 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { }, webOpts...) } + var wsSrv *website.Server + wsTLSCert := "" + wsTLSKey := "" + if len(cfg.WebsitePorts) > 0 { + for _, addr := range cfg.WebsitePorts { + if utils.IsUnixSocketPath(addr) { + continue + } + _, wsPrt, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("website listen address must be in the form ':port' or 'host:port': %w", err) + } + wsPortNum, err := strconv.Atoi(wsPrt) + if err != nil { + return fmt.Errorf("website port must be a number: %w", err) + } + if wsPortNum < 0 || wsPortNum > 65535 { + return fmt.Errorf("website port must be between 0 and 65535") + } + } + + var wsOpts []website.Option + if !cfg.WebsiteNoTLS { + wsTLSCert = cfg.WebsiteCertFile + wsTLSKey = cfg.WebsiteKeyFile + if wsTLSCert == "" && wsTLSKey == "" { + wsTLSCert = cfg.CertFile + wsTLSKey = cfg.KeyFile + } + if wsTLSCert != "" || wsTLSKey != "" { + if wsTLSCert == "" { + return fmt.Errorf("website TLS key specified without cert file") + } + if wsTLSKey == "" { + return fmt.Errorf("website TLS cert specified without key file") + } + cs := utils.NewCertStorage() + if err := cs.SetCertificate(wsTLSCert, wsTLSKey); err != nil { + return fmt.Errorf("tls: load certs: %v", err) + } + wsOpts = append(wsOpts, website.WithTLS(cs)) + } + } + + if cfg.Quiet { + wsOpts = append(wsOpts, website.WithQuiet()) + } + if cfg.SocketPerm != "" { + wsOpts = append(wsOpts, website.WithSocketPerm(parsedSocketPerm)) + } + + wsSrv = website.NewServer(be, cfg.WebsiteDomain, wsOpts...) + } + if !cfg.Quiet { cfg.printBanner() } @@ -893,6 +971,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if len(cfg.WebuiPorts) > 0 { servers++ } + if len(cfg.WebsitePorts) > 0 { + servers++ + } c := make(chan error, servers) go func() { c <- srv.ServeMultiPort(cfg.Ports) }() @@ -902,6 +983,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if len(cfg.WebuiPorts) > 0 { go func() { c <- webSrv.ServeMultiPort(cfg.WebuiPorts) }() } + if len(cfg.WebsitePorts) > 0 { + go func() { c <- wsSrv.ServeMultiPort(cfg.WebsitePorts) }() + } // build a nil-safe sighup channel so the select below is always valid var sigHup <-chan struct{} @@ -957,6 +1041,14 @@ Loop: fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey) } } + if len(cfg.WebsitePorts) > 0 && wsTLSCert != "" && wsTLSKey != "" { + reloadErr := wsSrv.CertStorage.SetCertificate(wsTLSCert, wsTLSKey) + if reloadErr != nil { + debuglogger.InternalError(fmt.Errorf("wsSrv cert reload failed: %w", reloadErr)) + } else { + fmt.Printf("wsSrv cert reloaded (cert: %s, key: %s)\n", wsTLSCert, wsTLSKey) + } + } } } saveErr := err @@ -980,6 +1072,13 @@ Loop: } } + if wsSrv != nil { + err := wsSrv.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown website server: %v\n", err) + } + } + be.Shutdown() err = iam.Shutdown() @@ -1023,6 +1122,7 @@ func (cfg Config) printBanner() { ssl := cfg.CertFile != "" || cfg.KeyFile != "" admSSL := cfg.AdminCertFile != "" || cfg.AdminKeyFile != "" webuiSsl := !cfg.WebuiNoTLS && (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "") + websiteSsl := !cfg.WebsiteNoTLS && (cfg.WebsiteCertFile != "" || cfg.WebsiteKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "") if len(cfg.Ports) == 0 { fmt.Fprintf(os.Stderr, "No ports specified\n") @@ -1243,6 +1343,68 @@ func (cfg Config) printBanner() { } } + if len(cfg.WebsitePorts) > 0 { + var allWebsiteInterfaces []string + websiteInterfaceMap := make(map[string]bool) + + for _, websiteAddr := range cfg.WebsitePorts { + if strings.TrimSpace(websiteAddr) == "" { + continue + } + if utils.IsUnixSocketPath(websiteAddr) { + if !websiteInterfaceMap[websiteAddr] { + websiteInterfaceMap[websiteAddr] = true + allWebsiteInterfaces = append(allWebsiteInterfaces, websiteAddr) + } + continue + } + websiteInterfaces, err := getMatchingIPs(websiteAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to match website port local IP addresses for %s: %v\n", websiteAddr, err) + continue + } + _, websitePrt, err := net.SplitHostPort(websiteAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse website port %s: %v\n", websiteAddr, err) + continue + } + for _, ip := range websiteInterfaces { + key := net.JoinHostPort(ip, websitePrt) + if !websiteInterfaceMap[key] { + websiteInterfaceMap[key] = true + allWebsiteInterfaces = append(allWebsiteInterfaces, key) + } + } + } + + if len(allWebsiteInterfaces) > 0 { + domainInfo := "" + if cfg.WebsiteDomain != "" { + domainInfo = fmt.Sprintf(" (domain: %s)", cfg.WebsiteDomain) + } + lines = append(lines, + centerText(""), + leftText("Website endpoint listening on:"+domainInfo), + ) + for _, addrPort := range allWebsiteInterfaces { + if utils.IsUnixSocketPath(addrPort) { + lines = append(lines, leftText(" unix:"+addrPort)) + continue + } + ip, prt, err := net.SplitHostPort(addrPort) + if err != nil { + continue + } + hostPort := net.JoinHostPort(ip, prt) + u := fmt.Sprintf("http://%s", hostPort) + if websiteSsl { + u = fmt.Sprintf("https://%s", hostPort) + } + lines = append(lines, leftText(" "+u)) + } + } + } + fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐") for _, line := range lines { fmt.Printf("│%-*s│\n", columnWidth-2, line) @@ -1436,13 +1598,13 @@ func sortGatewayURLs(urls []string) { } // validatePortConflicts checks for port conflicts across the S3 API, admin, -// and WebUI port lists before the servers are started. +// WebUI, and website port lists before the servers are started. // // A bare port spec (e.g. ":7071") binds to all interfaces and conflicts with // any other spec on the same port number. Two identical "ip:port" specs are // allowed and will be caught by the OS later. UNIX socket paths are checked // for duplicate path conflicts only and never conflict with TCP specs. -func validatePortConflicts(ports, admPorts, webuiPorts []string) error { +func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) error { type portSpec struct { spec string port string @@ -1504,6 +1666,23 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { }) } + for _, p := range websitePorts { + if utils.IsUnixSocketPath(p) { + allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "website"}) + continue + } + _, port, err := net.SplitHostPort(p) + if err != nil { + continue + } + allSpecs = append(allSpecs, portSpec{ + spec: p, + port: port, + isBare: strings.HasPrefix(p, ":"), + portType: "website", + }) + } + for i, spec1 := range allSpecs { for j, spec2 := range allSpecs { if i >= j { diff --git a/embedgw/embedgw_test.go b/embedgw/embedgw_test.go index 781b14a7..a692cd7d 100644 --- a/embedgw/embedgw_test.go +++ b/embedgw/embedgw_test.go @@ -20,12 +20,13 @@ import ( func TestValidatePortConflicts(t *testing.T) { tests := []struct { - name string - ports []string - admPorts []string - webuiPorts []string - expectError bool - description string + name string + ports []string + admPorts []string + webuiPorts []string + websitePorts []string + expectError bool + description string }{ { name: "bare port conflict with bare port", @@ -115,11 +116,38 @@ func TestValidatePortConflicts(t *testing.T) { expectError: true, description: "should fail: :8080 conflicts with 127.0.0.1:8080", }, + { + name: "website bare port conflict with s3 port", + ports: []string{"127.0.0.1:8080"}, + admPorts: []string{}, + webuiPorts: []string{}, + websitePorts: []string{":8080"}, + expectError: true, + description: "should fail: website bare :8080 conflicts with s3 127.0.0.1:8080", + }, + { + name: "website no conflict", + ports: []string{":7070"}, + admPorts: []string{":8080"}, + webuiPorts: []string{":9090"}, + websitePorts: []string{":8081"}, + expectError: false, + description: "should pass: website uses a distinct port", + }, + { + name: "duplicate website unix socket conflict", + ports: []string{"/tmp/versitygw.sock"}, + admPorts: []string{}, + webuiPorts: []string{}, + websitePorts: []string{"/tmp/versitygw.sock"}, + expectError: true, + description: "should fail: duplicate unix socket path conflicts across s3 and website", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts) + err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts, tt.websitePorts) if tt.expectError && err == nil { t.Errorf("%s: expected error but got none", tt.description) } diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go index 028f4e48..bbad90e4 100644 --- a/s3api/controllers/base.go +++ b/s3api/controllers/base.go @@ -49,9 +49,10 @@ const ( iso8601TimeFormatExtended = "Mon Jan _2 15:04:05 2006" timefmt = "Mon, 02 Jan 2006 15:04:05 GMT" - maxXMLBodyLen = 4 * 1024 * 1024 - minPartNumber = 1 - maxPartNumber = 10000 + maxXMLBodyLen = 4 * 1024 * 1024 + minPartNumber = 1 + maxPartNumber = 10000 + maxWebsiteConfigurationBytes = 131072 defaultRegion = "us-east-1" defaultContentType = "binary/octet-stream" diff --git a/s3api/controllers/bucket-delete.go b/s3api/controllers/bucket-delete.go index 441f5273..2ff9e7d1 100644 --- a/s3api/controllers/bucket-delete.go +++ b/s3api/controllers/bucket-delete.go @@ -177,7 +177,7 @@ func (c S3ApiController) DeleteBucketWebsite(ctx *fiber.Ctx) (*Response, error) IsRoot: isRoot, Acc: acct, Bucket: bucket, - Action: auth.DeleteBucketWebsiteAction, + Actions: []auth.Action{auth.DeleteBucketWebsiteAction}, IsPublicRequest: IsBucketPublic, DisableACL: c.disableACL, }) diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go index 3a31156b..b86fd9b3 100644 --- a/s3api/controllers/bucket-get.go +++ b/s3api/controllers/bucket-get.go @@ -220,7 +220,7 @@ func (c S3ApiController) GetBucketWebsite(ctx *fiber.Ctx) (*Response, error) { IsRoot: isRoot, Acc: acct, Bucket: bucket, - Action: auth.GetBucketWebsiteAction, + Actions: []auth.Action{auth.GetBucketWebsiteAction}, IsPublicRequest: isPublicBucket, DisableACL: c.disableACL, }) diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go index a83c291f..6d1521b7 100644 --- a/s3api/controllers/bucket-put.go +++ b/s3api/controllers/bucket-put.go @@ -301,7 +301,7 @@ func (c S3ApiController) PutBucketWebsite(ctx *fiber.Ctx) (*Response, error) { IsRoot: isRoot, Acc: acct, Bucket: bucket, - Action: auth.PutBucketWebsiteAction, + Actions: []auth.Action{auth.PutBucketWebsiteAction}, IsPublicRequest: isPublicBucket, DisableACL: c.disableACL, }) @@ -314,6 +314,14 @@ func (c S3ApiController) PutBucketWebsite(ctx *fiber.Ctx) (*Response, error) { } body := ctx.Body() + if len(body) > maxWebsiteConfigurationBytes { + debuglogger.Logf("the request size exceeded the 128KB limit: %d", len(body)) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetMaxMessageLengthExceeded(maxWebsiteConfigurationBytes) + } var websiteConfig s3response.WebsiteConfiguration err = xml.Unmarshal(body, &websiteConfig) diff --git a/s3api/controllers/cors_default_origin_test.go b/s3api/controllers/cors_default_origin_test.go index 761aa551..a8771423 100644 --- a/s3api/controllers/cors_default_origin_test.go +++ b/s3api/controllers/cors_default_origin_test.go @@ -35,7 +35,7 @@ func TestApplyBucketCORS_FallbackOrigin_NoBucketCors_NoRequestOrigin(t *testing. app := fiber.New() app.Get("/:bucket/test", - middlewares.ApplyBucketCORS(mockedBackend, origin), + middlewares.ApplyBucketCORS(mockedBackend, middlewares.BucketFromPath, origin), func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }, @@ -71,7 +71,7 @@ func TestApplyBucketCORS_FallbackOrigin_NotAppliedWhenBucketCorsExists(t *testin app := fiber.New() app.Get("/:bucket/test", - middlewares.ApplyBucketCORS(mockedBackend, origin), + middlewares.ApplyBucketCORS(mockedBackend, middlewares.BucketFromPath, origin), func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }, diff --git a/s3api/middlewares/apply-bucket-cors.go b/s3api/middlewares/apply-bucket-cors.go index 9a19543e..0f2c3a81 100644 --- a/s3api/middlewares/apply-bucket-cors.go +++ b/s3api/middlewares/apply-bucket-cors.go @@ -28,21 +28,31 @@ import ( // Vary http response header is always the same below var VaryHdr = "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" +type BucketResolver func(ctx *fiber.Ctx) (string, error) + +func BucketFromPath(ctx *fiber.Ctx) (string, error) { + return ctx.Params("bucket"), nil +} + // ApplyBucketCORS retreives the bucket CORS configuration, // checks if origin and method meets the cors rules and // adds the necessary response headers. // CORS check is applied only when 'Origin' request header is present -func ApplyBucketCORS(be backend.Backend, fallbackOrigin string) fiber.Handler { +func ApplyBucketCORS(be backend.Backend, resolveBucket BucketResolver, fallbackOrigin string) fiber.Handler { fallbackOrigin = strings.TrimSpace(fallbackOrigin) return func(ctx *fiber.Ctx) error { - bucket := ctx.Params("bucket") origin := ctx.Get("Origin") // If neither Origin is present nor a fallback is configured, skip CORS entirely. if origin == "" && fallbackOrigin == "" { return nil } + bucket, err := resolveBucket(ctx) + if err != nil { + return err + } + // if bucket cors is not set, skip the check data, err := be.GetBucketCors(ctx.Context(), bucket) if err != nil { diff --git a/s3api/router.go b/s3api/router.go index e4b32432..57e4352b 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -185,6 +185,7 @@ func (sa *S3ApiRouter) Init() { bucketRouter := sa.app.Group("/:bucket") objectRouter := sa.app.Group("/:bucket/*") + applyBucketCORS := middlewares.ApplyBucketCORS(sa.be, middlewares.BucketFromPath, sa.corsAllowOrigin) // PUT bucket operations bucketRouter.Put("", @@ -194,7 +195,7 @@ func (sa *S3ApiRouter) Init() { metrics.ActionPutBucketTagging, services, middlewares.BucketObjectNameValidator(), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), @@ -212,7 +213,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -226,7 +227,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -240,7 +241,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -254,7 +255,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -268,7 +269,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -282,7 +283,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -452,7 +453,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -466,7 +467,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, )) // HeadBucket action @@ -491,7 +492,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionHeadBucket, auth.ListBucketAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -518,7 +519,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Delete("", @@ -531,7 +532,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketOwnershipControls, auth.PutBucketOwnershipControlsAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Delete("", @@ -544,7 +545,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketPolicy, auth.PutBucketPolicyAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Delete("", @@ -557,7 +558,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketCors, auth.PutBucketCorsAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Delete("", @@ -674,7 +675,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketWebsite, auth.DeleteBucketWebsiteAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -687,7 +688,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucket, auth.DeleteBucketAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -714,7 +715,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketLocation, auth.GetBucketLocationAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -728,7 +729,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketTagging, auth.GetBucketTaggingAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -741,7 +742,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketOwnershipControls, auth.GetBucketOwnershipControlsAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -754,7 +755,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketVersioning, auth.GetBucketVersioningAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -767,7 +768,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketPolicy, auth.GetBucketPolicyAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -780,7 +781,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketCors, auth.GetBucketCorsAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -793,7 +794,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectLockConfiguration, auth.GetBucketObjectLockConfigurationAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -806,7 +807,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketAcl, auth.GetBucketAclAction, auth.PermissionReadAcp, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -819,7 +820,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListMultipartUploads, auth.ListBucketMultipartUploadsAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -832,7 +833,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListObjectVersions, auth.ListBucketVersionsAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -845,7 +846,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketPolicyStatus, auth.GetBucketPolicyStatusAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -1066,7 +1067,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketWebsite, auth.GetBucketWebsiteAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -1080,7 +1081,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListObjectsV2, auth.ListBucketAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Get("", @@ -1092,7 +1093,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListObjects, auth.ListBucketAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1121,7 +1122,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1133,7 +1134,7 @@ func (sa *S3ApiRouter) Init() { middlewares.BucketObjectNameValidator(), middlewares.AuthorizePostObject(sa.root, sa.iam, sa.region), middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPostObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1159,7 +1160,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionHeadObject, auth.GetObjectAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1199,7 +1200,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectTagging, auth.GetObjectTaggingAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Get("", @@ -1212,7 +1213,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectRetention, auth.GetObjectRetentionAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Get("", @@ -1225,7 +1226,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectLegalHold, auth.GetObjectLegalHoldAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Get("", @@ -1238,7 +1239,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectAcl, auth.GetObjectAclAction, auth.PermissionReadAcp, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Get("", @@ -1251,7 +1252,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectAttributes, auth.GetObjectAttributesAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Get("", @@ -1264,7 +1265,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListParts, auth.ListMultipartUploadPartsAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Get("", @@ -1276,7 +1277,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObject, auth.GetObjectAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1304,7 +1305,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteObjectTagging, auth.DeleteObjectTaggingAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Delete("", @@ -1317,7 +1318,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionAbortMultipartUpload, auth.AbortMultipartUploadAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Delete("", @@ -1329,7 +1330,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteObject, auth.DeleteObjectAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1359,7 +1360,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Post("", @@ -1374,7 +1375,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Post("", @@ -1387,7 +1388,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionCompleteMultipartUpload, auth.PutObjectAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Post("", @@ -1400,7 +1401,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionCreateMultipartUpload, auth.PutObjectAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1412,7 +1413,7 @@ func (sa *S3ApiRouter) Init() { metrics.ActionPutObjectTagging, services, middlewares.BucketObjectNameValidator(), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutObjectTagging, auth.PutObjectTaggingAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), @@ -1430,7 +1431,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Put("", @@ -1444,7 +1445,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Put("", @@ -1458,7 +1459,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Put("", @@ -1472,7 +1473,7 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionUploadPartCopy, auth.PutObjectAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) objectRouter.Put("", @@ -1486,7 +1487,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, true), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, true, true, false), middlewares.VerifyChecksums(true, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) @@ -1523,7 +1524,7 @@ func (sa *S3ApiRouter) Init() { metrics.ActionCopyObject, services, middlewares.BucketObjectNameValidator(), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionCopyObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), @@ -1535,7 +1536,7 @@ func (sa *S3ApiRouter) Init() { metrics.ActionPutObject, services, middlewares.BucketObjectNameValidator(), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, true), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, true), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, true, true, false), diff --git a/s3err/access-forbidden-error.go b/s3err/access-forbidden-error.go index 085c3d36..6abee327 100644 --- a/s3err/access-forbidden-error.go +++ b/s3err/access-forbidden-error.go @@ -43,6 +43,13 @@ func (e AccessForbiddenError) XMLBody(requestID, hostID string) []byte { }) } +func (e AccessForbiddenError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Method", Value: e.Method}, + ErrorField{Name: "ResourceType", Value: e.ResourceType}, + ) +} + func (e AccessForbiddenError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/bad-digest-error.go b/s3err/bad-digest-error.go index 1b209a4d..c095bd5a 100644 --- a/s3err/bad-digest-error.go +++ b/s3err/bad-digest-error.go @@ -43,6 +43,13 @@ func (e BadDigestError) XMLBody(requestID, hostID string) []byte { }) } +func (e BadDigestError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "CalculatedDigest", Value: e.CalculatedDigest}, + ErrorField{Name: "ExpectedDigest", Value: e.ExpectedDigest}, + ) +} + func (e BadDigestError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/bucket-error.go b/s3err/bucket-error.go index 0bbff389..37b4fef6 100644 --- a/s3err/bucket-error.go +++ b/s3err/bucket-error.go @@ -40,6 +40,12 @@ func (e BucketError) XMLBody(requestID, hostID string) []byte { }) } +func (e BucketError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "BucketName", Value: e.BucketName}, + ) +} + func (e BucketError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/content-sha256-mismatch-error.go b/s3err/content-sha256-mismatch-error.go index 22e9738f..1a3079ff 100644 --- a/s3err/content-sha256-mismatch-error.go +++ b/s3err/content-sha256-mismatch-error.go @@ -44,6 +44,13 @@ func (e ContentSHA256MismatchError) XMLBody(requestID, hostID string) []byte { }) } +func (e ContentSHA256MismatchError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ClientComputedContentSHA256", Value: e.ClientComputedContentSHA256}, + ErrorField{Name: "S3ComputedContentSHA256", Value: e.S3ComputedContentSHA256}, + ) +} + func (e ContentSHA256MismatchError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/entity-too-large-error.go b/s3err/entity-too-large-error.go index 24f40868..a6a4a96c 100644 --- a/s3err/entity-too-large-error.go +++ b/s3err/entity-too-large-error.go @@ -43,6 +43,13 @@ func (e EntityTooLargeError) XMLBody(requestID, hostID string) []byte { }) } +func (e EntityTooLargeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ProposedSize", Value: e.ProposedSize}, + ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed}, + ) +} + func (e EntityTooLargeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/entity-too-small-error.go b/s3err/entity-too-small-error.go index d1c522b0..7a230bb9 100644 --- a/s3err/entity-too-small-error.go +++ b/s3err/entity-too-small-error.go @@ -43,6 +43,13 @@ func (e EntityTooSmallError) XMLBody(requestID, hostID string) []byte { }) } +func (e EntityTooSmallError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ProposedSize", Value: e.ProposedSize}, + ErrorField{Name: "MinSizeAllowed", Value: e.MinSizeAllowed}, + ) +} + func (e EntityTooSmallError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/expired-presigned-url-error.go b/s3err/expired-presigned-url-error.go index f4406c86..95c4f815 100644 --- a/s3err/expired-presigned-url-error.go +++ b/s3err/expired-presigned-url-error.go @@ -46,6 +46,14 @@ func (e ExpiredPresignedURLError) XMLBody(requestID, hostID string) []byte { }) } +func (e ExpiredPresignedURLError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ServerTime", Value: e.ServerTime}, + ErrorField{Name: "X-Amz-Expires", Value: e.XAmzExpires}, + ErrorField{Name: "Expires", Value: e.Expires}, + ) +} + func (e ExpiredPresignedURLError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-access-key-id-error.go b/s3err/invalid-access-key-id-error.go index b70efa3c..8f910db7 100644 --- a/s3err/invalid-access-key-id-error.go +++ b/s3err/invalid-access-key-id-error.go @@ -40,6 +40,12 @@ func (e InvalidAccessKeyIdError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidAccessKeyIdError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId}, + ) +} + func (e InvalidAccessKeyIdError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-argument.go b/s3err/invalid-argument.go index ae14ca81..5e8d65b5 100644 --- a/s3err/invalid-argument.go +++ b/s3err/invalid-argument.go @@ -56,6 +56,8 @@ const ( InvalidArgCannedAcl InvalidArgOnlyAws4HmacSha256 InvalidArgDateHeader + InvalidArgIndexDocumentSuffix + InvalidArgErrorDocumentKey ) var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{ @@ -187,6 +189,14 @@ var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{ Description: "X-Amz-Date must be formated via ISO8601 Long format", ArgumentName: "X-Amz-Date", }, + InvalidArgIndexDocumentSuffix: { + Description: "The IndexDocument Suffix is not well formed", + ArgumentName: "IndexDocument", + }, + InvalidArgErrorDocumentKey: { + Description: "The ErrorDocument Key is not well formed", + ArgumentName: "ErrorDocument", + }, } // InvalidArgumentError is returned when a request argument is invalid. @@ -235,6 +245,13 @@ func (e InvalidArgumentError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidArgumentError) HTMLBody(requestID, hostID string) []byte { + return e.BaseError().encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ArgumentName", Value: e.ArgumentName}, + ErrorField{Name: "ArgumentValue", Value: e.ArgumentValue}, + ) +} + func GetInvalidArgumentErr(code InvalidArgErrorCode, value string) InvalidArgumentError { err := invalidArgErrResponses[code] err.ArgumentValue = value diff --git a/s3err/invalid-chunk-size-error.go b/s3err/invalid-chunk-size-error.go index 86eff473..7c147a4d 100644 --- a/s3err/invalid-chunk-size-error.go +++ b/s3err/invalid-chunk-size-error.go @@ -43,6 +43,13 @@ func (e InvalidChunkSizeError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidChunkSizeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Chunk", Value: e.Chunk}, + ErrorField{Name: "BadChunkSize", Value: e.BadChunkSize}, + ) +} + func (e InvalidChunkSizeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-digest-error.go b/s3err/invalid-digest-error.go index 537d0906..e7ca3113 100644 --- a/s3err/invalid-digest-error.go +++ b/s3err/invalid-digest-error.go @@ -40,6 +40,12 @@ func (e InvalidDigestError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidDigestError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Content-MD5", Value: e.ContentMD5}, + ) +} + func (e InvalidDigestError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-location-constraint-error.go b/s3err/invalid-location-constraint-error.go index 290d6428..bf27a5d7 100644 --- a/s3err/invalid-location-constraint-error.go +++ b/s3err/invalid-location-constraint-error.go @@ -40,6 +40,12 @@ func (e InvalidLocationConstraintError) XMLBody(requestID, hostID string) []byte }) } +func (e InvalidLocationConstraintError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "LocationConstraint", Value: e.LocationConstraint}, + ) +} + func (e InvalidLocationConstraintError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-part-error.go b/s3err/invalid-part-error.go index ed47cdcb..4cc7fc11 100644 --- a/s3err/invalid-part-error.go +++ b/s3err/invalid-part-error.go @@ -48,6 +48,14 @@ func (e InvalidPartError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidPartError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "UploadId", Value: e.UploadId}, + ErrorField{Name: "PartNumber", Value: e.PartNumber}, + ErrorField{Name: "ETag", Value: e.ETag}, + ) +} + func (e InvalidPartError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-part-number-range-error.go b/s3err/invalid-part-number-range-error.go index 5edd9fea..4cf7dbfc 100644 --- a/s3err/invalid-part-number-range-error.go +++ b/s3err/invalid-part-number-range-error.go @@ -44,6 +44,13 @@ func (e InvalidPartNumberRangeError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidPartNumberRangeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ActualPartCount", Value: e.ActualPartCount}, + ErrorField{Name: "PartNumberRequested", Value: e.PartNumberRequested}, + ) +} + func (e InvalidPartNumberRangeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-range-error.go b/s3err/invalid-range-error.go index 37773768..bc23711d 100644 --- a/s3err/invalid-range-error.go +++ b/s3err/invalid-range-error.go @@ -43,6 +43,13 @@ func (e InvalidRangeError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidRangeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "RangeRequested", Value: e.RangeRequested}, + ErrorField{Name: "ActualObjectSize", Value: e.ActualObjectSize}, + ) +} + func (e InvalidRangeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-tag-error.go b/s3err/invalid-tag-error.go index 981c5183..2c481028 100644 --- a/s3err/invalid-tag-error.go +++ b/s3err/invalid-tag-error.go @@ -43,6 +43,13 @@ func (e InvalidTagError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidTagError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "TagKey", Value: e.TagKey}, + ErrorField{Name: "TagValue", Value: e.TagValue}, + ) +} + func (e InvalidTagError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/key-too-long-error.go b/s3err/key-too-long-error.go index d95fdd8a..073d56f7 100644 --- a/s3err/key-too-long-error.go +++ b/s3err/key-too-long-error.go @@ -43,6 +43,13 @@ func (e KeyTooLongError) XMLBody(requestID, hostID string) []byte { }) } +func (e KeyTooLongError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Size", Value: e.Size}, + ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed}, + ) +} + func (e KeyTooLongError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/max-message-length-exceeded-error.go b/s3err/max-message-length-exceeded-error.go new file mode 100644 index 00000000..1c9447d4 --- /dev/null +++ b/s3err/max-message-length-exceeded-error.go @@ -0,0 +1,59 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package s3err + +import "encoding/xml" + +// MaxMessageLengthExceeded is returned when the request size exceeds the maximum limit +// Produces the field in the XML response. +type MaxMessageLengthExceeded struct { + APIError + MaxMessageLengthBytes int64 +} + +func (e MaxMessageLengthExceeded) XMLBody(requestID, hostID string) []byte { + return encodeResponse(struct { + XMLName xml.Name `xml:"Error"` + Code string + Message string + MaxMessageLengthBytes int64 `xml:",omitempty"` + RequestID string `xml:"RequestId,omitempty"` + HostID string `xml:"HostId,omitempty"` + }{ + Code: e.Code, + Message: e.Description, + MaxMessageLengthBytes: e.MaxMessageLengthBytes, + RequestID: requestID, + HostID: hostID, + }) +} + +func (e MaxMessageLengthExceeded) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "MaxMessageLengthBytes", Value: e.MaxMessageLengthBytes}, + ) +} + +func (e MaxMessageLengthExceeded) Is(target error) bool { + t, ok := target.(APIError) + return ok && e.APIError == t +} + +func GetMaxMessageLengthExceeded(maxMessageLengthBytes int64) MaxMessageLengthExceeded { + return MaxMessageLengthExceeded{ + APIError: GetAPIError(ErrMaxMessageLengthExceeded), + MaxMessageLengthBytes: maxMessageLengthBytes, + } +} diff --git a/s3err/metadata-too-large-error.go b/s3err/metadata-too-large-error.go index 02bc2692..59cd6e87 100644 --- a/s3err/metadata-too-large-error.go +++ b/s3err/metadata-too-large-error.go @@ -43,6 +43,13 @@ func (e MetadataTooLargeError) XMLBody(requestID, hostID string) []byte { }) } +func (e MetadataTooLargeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Size", Value: e.Size}, + ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed}, + ) +} + func (e MetadataTooLargeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/method-not-allowed-error.go b/s3err/method-not-allowed-error.go index 881c9acf..a0009f28 100644 --- a/s3err/method-not-allowed-error.go +++ b/s3err/method-not-allowed-error.go @@ -52,6 +52,13 @@ func (e MethodNotAllowedError) XMLBody(requestID, hostID string) []byte { }) } +func (e MethodNotAllowedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Method", Value: e.Method}, + ErrorField{Name: "ResourceType", Value: e.ResourceType}, + ) +} + func (e MethodNotAllowedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/no-such-upload-error.go b/s3err/no-such-upload-error.go index a1576a43..2d6d8452 100644 --- a/s3err/no-such-upload-error.go +++ b/s3err/no-such-upload-error.go @@ -40,6 +40,12 @@ func (e NoSuchUploadError) XMLBody(requestID, hostID string) []byte { }) } +func (e NoSuchUploadError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "UploadId", Value: e.UploadId}, + ) +} + func (e NoSuchUploadError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/no-such-version-error.go b/s3err/no-such-version-error.go index 8cd36e60..b1ed81c5 100644 --- a/s3err/no-such-version-error.go +++ b/s3err/no-such-version-error.go @@ -43,6 +43,13 @@ func (e NoSuchVersionError) XMLBody(requestID, hostID string) []byte { }) } +func (e NoSuchVersionError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Key", Value: e.Key}, + ErrorField{Name: "VersionId", Value: e.VersionId}, + ) +} + func (e NoSuchVersionError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/not-implemented-error.go b/s3err/not-implemented-error.go index 64569a69..f4618238 100644 --- a/s3err/not-implemented-error.go +++ b/s3err/not-implemented-error.go @@ -52,6 +52,13 @@ func (e NotImplementedError) XMLBody(requestID, hostID string) []byte { }) } +func (e NotImplementedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Header", Value: e.Header}, + ErrorField{Name: "additionalMessage", Value: e.AdditionalMessage}, + ) +} + func (e NotImplementedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/precondition-failed-error.go b/s3err/precondition-failed-error.go index 0e8fe3fb..f54b79d6 100644 --- a/s3err/precondition-failed-error.go +++ b/s3err/precondition-failed-error.go @@ -52,6 +52,12 @@ func (e PreconditionFailedError) XMLBody(requestID, hostID string) []byte { }) } +func (e PreconditionFailedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Condition", Value: e.Condition}, + ) +} + func (e PreconditionFailedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/request-time-too-skewed-error.go b/s3err/request-time-too-skewed-error.go index b40eb109..d5ba3785 100644 --- a/s3err/request-time-too-skewed-error.go +++ b/s3err/request-time-too-skewed-error.go @@ -48,6 +48,14 @@ func (e RequestTimeTooSkewedError) XMLBody(requestID, hostID string) []byte { }) } +func (e RequestTimeTooSkewedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "RequestTime", Value: e.RequestTime}, + ErrorField{Name: "ServerTime", Value: e.ServerTime}, + ErrorField{Name: "MaxAllowedSkewMilliseconds", Value: e.MaxAllowedSkewMilliseconds}, + ) +} + func (e RequestTimeTooSkewedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/s3err.go b/s3err/s3err.go index ac3fe57c..8aa15e20 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -18,6 +18,7 @@ import ( "bytes" "encoding/xml" "fmt" + "html" "net/http" "strings" @@ -31,6 +32,7 @@ type S3Error interface { StatusCode() int BaseError() APIError XMLBody(requestID, hostID string) []byte + HTMLBody(requestID, hostID string) []byte } // APIError structure @@ -69,6 +71,10 @@ func (e APIError) XMLBody(requestID, hostID string) []byte { }) } +func (e APIError) HTMLBody(requestID, hostID string) []byte { + return e.encodeHTMLResponse(requestID, hostID) +} + // ErrorCode type of error status. type ErrorCode int @@ -166,9 +172,9 @@ const ( ErrMissingCORSOrigin ErrCORSIsNotEnabled ErrNoSuchWebsiteConfiguration - ErrInvalidWebsiteConfiguration - ErrInvalidWebsiteSuffix - ErrInvalidWebsiteRedirectCode + ErrInvalidWebsiteRedirectProtocol + ErrBothReplaceKeyAndPrefix + ErrMaxMessageLengthExceeded ErrNotModified ErrInvalidLocationConstraint ErrMalformedTrailer @@ -176,6 +182,7 @@ const ( ErrSlowDown ErrMetadataTooLarge ErrUnsupportedAuthorizationMechanism + ErrNoBucketInRequest // Non-AWS errors ErrExistingObjectIsDirectory @@ -648,19 +655,19 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "The specified bucket does not have a website configuration", HTTPStatusCode: http.StatusNotFound, }, - ErrInvalidWebsiteConfiguration: { - Code: "MalformedXML", - Description: "The XML you provided was not well-formed or did not validate against our published schema.", + ErrInvalidWebsiteRedirectProtocol: { + Code: "InvalidRequest", + Description: "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically.", HTTPStatusCode: http.StatusBadRequest, }, - ErrInvalidWebsiteSuffix: { - Code: "InvalidArgument", - Description: "The IndexDocument Suffix is not well formed", + ErrBothReplaceKeyAndPrefix: { + Code: "InvalidRequest", + Description: "You can only define ReplaceKeyPrefix or ReplaceKey but not both.", HTTPStatusCode: http.StatusBadRequest, }, - ErrInvalidWebsiteRedirectCode: { - Code: "InvalidArgument", - Description: "The website redirect code is not valid. Valid codes are 3XX.", + ErrMaxMessageLengthExceeded: { + Code: "MaxMessageLengthExceeded", + Description: "Your request was too big.", HTTPStatusCode: http.StatusBadRequest, }, ErrNotModified: { @@ -698,6 +705,11 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "The authorization mechanism you have provided is not supported. Please use AWS4-HMAC-SHA256.", HTTPStatusCode: http.StatusBadRequest, }, + ErrNoBucketInRequest: { + Code: "WebsiteRedirect", + Description: "Request does not contain a bucket name.", + HTTPStatusCode: http.StatusMovedPermanently, + }, // non aws errors ErrExistingObjectIsDirectory: { @@ -793,6 +805,42 @@ func encodeResponse(response any) []byte { return bytesBuffer.Bytes() } +type ErrorField struct { + Name string + Value any +} + +func (e APIError) encodeHTMLResponse(requestID, hostID string, fields ...ErrorField) []byte { + status := fmt.Sprintf("%d %s", e.HTTPStatusCode, http.StatusText(e.HTTPStatusCode)) + + builder := &strings.Builder{} + builder.WriteString("\n") + builder.WriteString("") + builder.WriteString(html.EscapeString(status)) + builder.WriteString("\n\n

") + builder.WriteString(html.EscapeString(status)) + builder.WriteString("

\n\n
\n\n\n") + return []byte(builder.String()) +} + +func writeHTMLErrorField(builder *strings.Builder, name string, value any) { + builder.WriteString("
  • ") + builder.WriteString(html.EscapeString(name)) + builder.WriteString(": ") + builder.WriteString(html.EscapeString(fmt.Sprint(value))) + builder.WriteString("
  • \n") +} + // Returns invalid checksum error with the provided header in the error description func GetInvalidChecksumHeaderErr(header string) APIError { return APIError{ @@ -918,6 +966,30 @@ func GetCopySourceObjectTooLargeErr(limit int64) APIError { } } +func GetInvalidRedirectCodeErr(input int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided HTTP redirect code (%d) is not valid. Valid codes are 3XX except 300.", input), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidHTTPErrorCodeErr(input int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided HTTP error code (%d) is not valid. Valid codes are 4XX or 5XX.", input), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetWebsiteRoutingRulesLimitedErr(rules int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("%d routing rules provided, the number of routing rules in a website configuration is limited to 50.", rules), + HTTPStatusCode: http.StatusBadRequest, + } +} + type ResourceType string const ( diff --git a/s3err/signature-does-not-match-error.go b/s3err/signature-does-not-match-error.go index 97cfeb96..51ec315f 100644 --- a/s3err/signature-does-not-match-error.go +++ b/s3err/signature-does-not-match-error.go @@ -55,6 +55,17 @@ func (e SignatureDoesNotMatchError) XMLBody(requestID, hostID string) []byte { }) } +func (e SignatureDoesNotMatchError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId}, + ErrorField{Name: "StringToSign", Value: e.StringToSign}, + ErrorField{Name: "SignatureProvided", Value: e.SignatureProvided}, + ErrorField{Name: "StringToSignBytes", Value: e.StringToSignBytes}, + ErrorField{Name: "CanonicalRequest", Value: e.CanonicalRequest}, + ErrorField{Name: "CanonicalRequestBytes", Value: e.CanonicalRequestBytes}, + ) +} + func (e SignatureDoesNotMatchError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/sigv4.go b/s3err/sigv4.go index a868089d..ebb25be3 100644 --- a/s3err/sigv4.go +++ b/s3err/sigv4.go @@ -44,6 +44,12 @@ func (e MalformedAuthError) XMLBody(requestID, hostID string) []byte { }) } +func (e MalformedAuthError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Region", Value: e.Region}, + ) +} + func (e MalformedAuthError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3response/website.go b/s3response/website.go index 16884e38..976a0769 100644 --- a/s3response/website.go +++ b/s3response/website.go @@ -17,8 +17,10 @@ package s3response import ( "encoding/xml" "fmt" + "strconv" "strings" + "github.com/versity/versitygw/debuglogger" "github.com/versity/versitygw/s3err" ) @@ -74,10 +76,12 @@ type Redirect struct { func (c *WebsiteConfiguration) Validate() error { if c.RedirectAllRequestsTo != nil { if c.IndexDocument != nil || c.ErrorDocument != nil || len(c.RoutingRules) > 0 { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) + debuglogger.Logf("website redirect conflicts with config") + return s3err.GetAPIError(s3err.ErrMalformedXML) } if c.RedirectAllRequestsTo.HostName == "" { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) + debuglogger.Logf("website redirect hostname is empty") + return s3err.GetAPIError(s3err.ErrMalformedXML) } if err := validateProtocol(c.RedirectAllRequestsTo.Protocol); err != nil { return err @@ -86,26 +90,31 @@ func (c *WebsiteConfiguration) Validate() error { } if c.IndexDocument == nil { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) + debuglogger.Logf("website index document is missing") + return s3err.GetAPIError(s3err.ErrMalformedXML) } if c.IndexDocument.Suffix == "" { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix) + debuglogger.Logf("website index suffix is empty") + return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix) } if strings.Contains(c.IndexDocument.Suffix, "/") { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix) + debuglogger.Logf("website index suffix contains slash") + return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix) } if c.ErrorDocument != nil && c.ErrorDocument.Key == "" { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) + debuglogger.Logf("website error document key is empty") + return s3err.GetInvalidArgumentErr(s3err.InvalidArgErrorDocumentKey, "") } if len(c.RoutingRules) > maxRoutingRules { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) + debuglogger.Logf("too many website routing rules: %d", len(c.RoutingRules)) + return s3err.GetWebsiteRoutingRulesLimitedErr(len(c.RoutingRules)) } - for i, rule := range c.RoutingRules { + for _, rule := range c.RoutingRules { if err := rule.Validate(); err != nil { - return fmt.Errorf("routing rule %d: %w", i, err) + return err } } @@ -114,27 +123,84 @@ func (c *WebsiteConfiguration) Validate() error { // Validate checks a single routing rule for validity. func (r *RoutingRule) Validate() error { - if r.Redirect.ReplaceKeyWith != "" && r.Redirect.ReplaceKeyPrefixWith != "" { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) - } - - if err := validateProtocol(r.Redirect.Protocol); err != nil { + if err := r.Redirect.Validate(); err != nil { return err } - if r.Redirect.HttpRedirectCode != "" { - code := r.Redirect.HttpRedirectCode - if len(code) != 3 || code[0] != '3' { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectCode) - } + if err := r.Condition.Validate(); err != nil { + return err } return nil } +func (c *RoutingRuleCondition) Validate() error { + if c == nil { + return nil + } + + return isValidHTTPCode(c.HttpErrorCodeReturnedEquals, validateErrorCode) +} + +func (r *Redirect) Validate() error { + if r.ReplaceKeyWith != "" && r.ReplaceKeyPrefixWith != "" { + debuglogger.Logf("website redirect has both key replacements") + return s3err.GetAPIError(s3err.ErrBothReplaceKeyAndPrefix) + } + + if err := validateProtocol(r.Protocol); err != nil { + return err + } + + if err := isValidHTTPCode(r.HttpRedirectCode, validateRedirectCode); err != nil { + return err + } + + return nil +} + +type httpCodeValidator func(code int) error + +func isValidHTTPCode(input string, validateCode httpCodeValidator) error { + if input == "" { + return nil + } + + code, err := strconv.Atoi(input) + if err != nil { + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + + return validateCode(code) +} + +// isValidErrorCode checks if the provided code is a valid +// HTTP error code: S3 considers 400-417 and 500-505 as valid +func validateErrorCode(code int) error { + if (code >= 400 && code <= 417) || (code >= 500 && code <= 505) { + return nil + } + + debuglogger.Logf("invalid website error code: %d", code) + return s3err.GetInvalidHTTPErrorCodeErr(code) +} + +// validateRedirectCode check if the provided code +// is a valid HTTP redirect code +func validateRedirectCode(code int) error { + switch code { + case 301, 302, 303, 304, 305, 307, 308: + return nil + } + + debuglogger.Logf("invalid website redirect code: %d", code) + return s3err.GetInvalidRedirectCodeErr(code) +} + func validateProtocol(protocol string) error { if protocol != "" && protocol != "http" && protocol != "https" { - return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration) + debuglogger.Logf("invalid website redirect protocol: %q", protocol) + return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol) } return nil } @@ -144,26 +210,27 @@ func ParseWebsiteConfigOutput(data []byte) (*WebsiteConfiguration, error) { var config WebsiteConfiguration err := xml.Unmarshal(data, &config) if err != nil { + debuglogger.Logf("failed to parse website config: %v", err) return nil, fmt.Errorf("failed to parse website config: %w", err) } return &config, nil } -// MatchPreRequestRule returns the first routing rule that matches based only -// on KeyPrefixEquals (i.e. rules without HttpErrorCodeReturnedEquals). These -// rules can be evaluated before the backend request is made. A rule with no -// condition at all is treated as an unconditional match. -func (c *WebsiteConfiguration) MatchPreRequestRule(key string) *RoutingRule { +// MatchPrefetchRoutingRule returns the first rule that can be evaluated before +// attempting an object read. Only prefix-only conditions participate in this +// phase. +func (c *WebsiteConfiguration) MatchPrefetchRoutingRule(key string) *RoutingRule { for i := range c.RoutingRules { rule := &c.RoutingRules[i] - - if rule.Condition != nil && rule.Condition.HttpErrorCodeReturnedEquals != "" { - // This is a post-request rule, skip it + condition := rule.Condition + if condition == nil || + condition.KeyPrefixEquals == "" || + condition.HttpErrorCodeReturnedEquals != "" { continue } - if rule.Condition == nil || strings.HasPrefix(key, rule.Condition.KeyPrefixEquals) { + if condition.KeyPrefixEquals != "" && strings.HasPrefix(key, condition.KeyPrefixEquals) { return rule } } @@ -171,28 +238,39 @@ func (c *WebsiteConfiguration) MatchPreRequestRule(key string) *RoutingRule { return nil } -// MatchPostRequestRule returns the first routing rule that matches based on -// HttpErrorCodeReturnedEquals (and optionally KeyPrefixEquals). These rules -// are evaluated after the backend returns an error. -func (c *WebsiteConfiguration) MatchPostRequestRule(key, httpErrorCode string) *RoutingRule { +// MatchPostErrorRoutingRule returns the first rule that matches after a 4xx +// object-read error. Prefix-only rules are skipped because they have already +// been evaluated in the pre-fetch phase. +func (c *WebsiteConfiguration) MatchPostErrorRoutingRule(key string, statusCode int) *RoutingRule { for i := range c.RoutingRules { rule := &c.RoutingRules[i] - - if rule.Condition == nil || rule.Condition.HttpErrorCodeReturnedEquals == "" { - // Not a post-request rule + condition := rule.Condition + if condition != nil && condition.HttpErrorCodeReturnedEquals == "" { continue } - if rule.Condition.HttpErrorCodeReturnedEquals != httpErrorCode { - continue + if condition.Matches(key, statusCode) { + return rule } - - if rule.Condition.KeyPrefixEquals != "" && !strings.HasPrefix(key, rule.Condition.KeyPrefixEquals) { - continue - } - - return rule } return nil } + +// Matches reports whether all configured condition fields match. +func (c *RoutingRuleCondition) Matches(key string, statusCode int) bool { + if c == nil { + return true + } + + if c.KeyPrefixEquals != "" && !strings.HasPrefix(key, c.KeyPrefixEquals) { + return false + } + + if c.HttpErrorCodeReturnedEquals != "" && + strconv.Itoa(statusCode) != c.HttpErrorCodeReturnedEquals { + return false + } + + return true +} diff --git a/s3response/website_test.go b/s3response/website_test.go index b644a154..a5867e7f 100644 --- a/s3response/website_test.go +++ b/s3response/website_test.go @@ -15,7 +15,7 @@ package s3response import ( - "encoding/xml" + "errors" "testing" "github.com/versity/versitygw/s3err" @@ -26,7 +26,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { name string config WebsiteConfiguration wantErr bool - errCode s3err.ErrorCode + errCode string }{ { name: "valid index document only", @@ -70,7 +70,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { name: "missing index document", config: WebsiteConfiguration{}, wantErr: true, - errCode: s3err.ErrInvalidWebsiteConfiguration, + errCode: "MalformedXML", }, { name: "empty index suffix", @@ -78,7 +78,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { IndexDocument: &IndexDocument{Suffix: ""}, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteSuffix, + errCode: "InvalidArgument", }, { name: "index suffix with slash", @@ -86,7 +86,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { IndexDocument: &IndexDocument{Suffix: "dir/index.html"}, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteSuffix, + errCode: "InvalidArgument", }, { name: "redirect all with index document", @@ -95,7 +95,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { IndexDocument: &IndexDocument{Suffix: "index.html"}, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteConfiguration, + errCode: "MalformedXML", }, { name: "redirect all with empty hostname", @@ -103,7 +103,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { RedirectAllRequestsTo: &RedirectAllRequestsTo{HostName: ""}, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteConfiguration, + errCode: "MalformedXML", }, { name: "redirect all with invalid protocol", @@ -114,7 +114,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { }, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteConfiguration, + errCode: "InvalidRequest", }, { name: "routing rule with both replace key fields", @@ -130,7 +130,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { }, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteConfiguration, + errCode: "InvalidRequest", }, { name: "routing rule with invalid redirect code", @@ -145,7 +145,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { }, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteRedirectCode, + errCode: "InvalidRequest", }, { name: "routing rule with valid redirect code", @@ -168,7 +168,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { ErrorDocument: &ErrorDocument{Key: ""}, }, wantErr: true, - errCode: s3err.ErrInvalidWebsiteConfiguration, + errCode: "InvalidArgument", }, } @@ -179,14 +179,12 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - apiErr, ok := err.(s3err.APIError) - if !ok { - // wrapped error from routing rule validation - return + var apiErr s3err.S3Error + if !errors.As(err, &apiErr) { + t.Fatalf("expected S3 error, got %T: %v", err, err) } - expectedErr := s3err.GetAPIError(tt.errCode) - if apiErr.Code != expectedErr.Code { - t.Errorf("expected error code %q, got %q", expectedErr.Code, apiErr.Code) + if apiErr.BaseError().Code != tt.errCode { + t.Errorf("expected error code %q, got %q", tt.errCode, apiErr.BaseError().Code) } } else { if err != nil { @@ -197,390 +195,83 @@ func TestWebsiteConfiguration_Validate(t *testing.T) { } } -func TestWebsiteConfiguration_XMLRoundTrip(t *testing.T) { - original := WebsiteConfiguration{ +func TestWebsiteConfiguration_MatchPrefetchRoutingRuleUsesPrefixOnlyRules(t *testing.T) { + config := WebsiteConfiguration{ IndexDocument: &IndexDocument{Suffix: "index.html"}, - ErrorDocument: &ErrorDocument{Key: "error.html"}, RoutingRules: []RoutingRule{ { Condition: &RoutingRuleCondition{ - KeyPrefixEquals: "docs/", HttpErrorCodeReturnedEquals: "404", }, Redirect: Redirect{ - HostName: "example.com", - Protocol: "https", - HttpRedirectCode: "301", - ReplaceKeyPrefixWith: "documents/", + HostName: "error.example.com", + }, + }, + { + Condition: &RoutingRuleCondition{ + KeyPrefixEquals: "old/", + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: Redirect{ + HostName: "both.example.com", + }, + }, + { + Condition: &RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: Redirect{ + HostName: "prefix.example.com", }, }, }, } - data, err := xml.Marshal(original) - if err != nil { - t.Fatalf("marshal: %v", err) + rule := config.MatchPrefetchRoutingRule("old/page.html") + if rule == nil { + t.Fatal("expected a matching rule, got nil") } - - var parsed WebsiteConfiguration - if err := xml.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if parsed.IndexDocument == nil || parsed.IndexDocument.Suffix != "index.html" { - t.Error("IndexDocument.Suffix mismatch") - } - if parsed.ErrorDocument == nil || parsed.ErrorDocument.Key != "error.html" { - t.Error("ErrorDocument.Key mismatch") - } - if len(parsed.RoutingRules) != 1 { - t.Fatalf("expected 1 routing rule, got %d", len(parsed.RoutingRules)) - } - rule := parsed.RoutingRules[0] - if rule.Condition == nil || rule.Condition.KeyPrefixEquals != "docs/" { - t.Error("RoutingRule Condition.KeyPrefixEquals mismatch") - } - if rule.Redirect.HostName != "example.com" { - t.Error("RoutingRule Redirect.HostName mismatch") - } - if rule.Redirect.ReplaceKeyPrefixWith != "documents/" { - t.Error("RoutingRule Redirect.ReplaceKeyPrefixWith mismatch") + if rule.Redirect.HostName != "prefix.example.com" { + t.Fatalf("expected prefix-only rule to match, got %q", rule.Redirect.HostName) } } -func TestParseWebsiteConfigOutput(t *testing.T) { - xmlData := ` - index.html - error.html - ` +func TestRoutingRuleCondition_MatchesUsesAndLogic(t *testing.T) { + condition := RoutingRuleCondition{ + KeyPrefixEquals: "old/", + HttpErrorCodeReturnedEquals: "404", + } - config, err := ParseWebsiteConfigOutput([]byte(xmlData)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if config.IndexDocument == nil || config.IndexDocument.Suffix != "index.html" { - t.Error("IndexDocument.Suffix mismatch") - } - if config.ErrorDocument == nil || config.ErrorDocument.Key != "error.html" { - t.Error("ErrorDocument.Key mismatch") - } -} - -func TestParseWebsiteConfigOutput_InvalidXML(t *testing.T) { - _, err := ParseWebsiteConfigOutput([]byte("not xml")) - if err == nil { - t.Fatal("expected error for invalid XML") - } -} - -func TestWebsiteConfiguration_MatchPreRequestRule(t *testing.T) { tests := []struct { - name string - config WebsiteConfiguration - key string - wantNil bool - wantHost string // expected redirect HostName if matched + name string + key string + statusCode int + want bool }{ { - name: "no routing rules", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - }, - key: "docs/page.html", - wantNil: true, + name: "both match", + key: "old/missing.html", + statusCode: 404, + want: true, }, { - name: "key prefix match", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "docs.example.com", - }, - }, - }, - }, - key: "docs/page.html", - wantHost: "docs.example.com", + name: "prefix only", + key: "old/existing.html", + statusCode: 200, + want: false, }, { - name: "key prefix does not match", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "docs.example.com", - }, - }, - }, - }, - key: "images/photo.jpg", - wantNil: true, - }, - { - name: "unconditional rule (no condition)", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Redirect: Redirect{ - HostName: "redirect.example.com", - }, - }, - }, - }, - key: "anything", - wantHost: "redirect.example.com", - }, - { - name: "skips post-request rules", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "error.example.com", - }, - }, - }, - }, - key: "docs/page.html", - wantNil: true, - }, - { - name: "first matching rule wins", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "first.example.com", - }, - }, - { - Condition: &RoutingRuleCondition{ - KeyPrefixEquals: "docs/api/", - }, - Redirect: Redirect{ - HostName: "second.example.com", - }, - }, - }, - }, - key: "docs/api/endpoint", - wantHost: "first.example.com", + name: "status only", + key: "other/missing.html", + statusCode: 404, + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rule := tt.config.MatchPreRequestRule(tt.key) - if tt.wantNil { - if rule != nil { - t.Fatalf("expected nil, got rule with redirect to %q", rule.Redirect.HostName) - } - return - } - if rule == nil { - t.Fatal("expected a matching rule, got nil") - } - if rule.Redirect.HostName != tt.wantHost { - t.Errorf("expected redirect host %q, got %q", tt.wantHost, rule.Redirect.HostName) - } - }) - } -} - -func TestWebsiteConfiguration_MatchPostRequestRule(t *testing.T) { - tests := []struct { - name string - config WebsiteConfiguration - key string - httpErrorCode string - wantNil bool - wantHost string - }{ - { - name: "no routing rules", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - }, - key: "page.html", - httpErrorCode: "404", - wantNil: true, - }, - { - name: "error code match", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - }, - Redirect: Redirect{ - HostName: "notfound.example.com", - }, - }, - }, - }, - key: "page.html", - httpErrorCode: "404", - wantHost: "notfound.example.com", - }, - { - name: "error code does not match", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - }, - Redirect: Redirect{ - HostName: "notfound.example.com", - }, - }, - }, - }, - key: "page.html", - httpErrorCode: "403", - wantNil: true, - }, - { - name: "error code and key prefix both match", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "docs-error.example.com", - }, - }, - }, - }, - key: "docs/missing.html", - httpErrorCode: "404", - wantHost: "docs-error.example.com", - }, - { - name: "error code matches but key prefix does not", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "docs-error.example.com", - }, - }, - }, - }, - key: "images/missing.jpg", - httpErrorCode: "404", - wantNil: true, - }, - { - name: "skips pre-request rules (no error code condition)", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "pre-request.example.com", - }, - }, - }, - }, - key: "docs/page.html", - httpErrorCode: "404", - wantNil: true, - }, - { - name: "skips rules with no condition", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Redirect: Redirect{ - HostName: "unconditional.example.com", - }, - }, - }, - }, - key: "page.html", - httpErrorCode: "404", - wantNil: true, - }, - { - name: "first matching rule wins", - config: WebsiteConfiguration{ - IndexDocument: &IndexDocument{Suffix: "index.html"}, - RoutingRules: []RoutingRule{ - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - }, - Redirect: Redirect{ - HostName: "first.example.com", - }, - }, - { - Condition: &RoutingRuleCondition{ - HttpErrorCodeReturnedEquals: "404", - KeyPrefixEquals: "docs/", - }, - Redirect: Redirect{ - HostName: "second.example.com", - }, - }, - }, - }, - key: "docs/page.html", - httpErrorCode: "404", - wantHost: "first.example.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rule := tt.config.MatchPostRequestRule(tt.key, tt.httpErrorCode) - if tt.wantNil { - if rule != nil { - t.Fatalf("expected nil, got rule with redirect to %q", rule.Redirect.HostName) - } - return - } - if rule == nil { - t.Fatal("expected a matching rule, got nil") - } - if rule.Redirect.HostName != tt.wantHost { - t.Errorf("expected redirect host %q, got %q", tt.wantHost, rule.Redirect.HostName) + if got := condition.Matches(tt.key, tt.statusCode); got != tt.want { + t.Fatalf("Matches() = %v, want %v", got, tt.want) } }) } diff --git a/tests/integration/PutBucketWebsite.go b/tests/integration/PutBucketWebsite.go index 11e97f38..ed0a09d4 100644 --- a/tests/integration/PutBucketWebsite.go +++ b/tests/integration/PutBucketWebsite.go @@ -16,12 +16,16 @@ package integration import ( "context" + "fmt" + "strings" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/versity/versitygw/s3err" ) +const maxWebsiteConfigSize = 131072 + func PutBucketWebsite_non_existing_bucket(s *S3Conf) error { testName := "PutBucketWebsite_non_existing_bucket" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { @@ -52,7 +56,7 @@ func PutBucketWebsite_empty_suffix(s *S3Conf) error { }, }) cancel() - return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)) + return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, "")) }) } @@ -69,7 +73,7 @@ func PutBucketWebsite_suffix_with_slash(s *S3Conf) error { }, }) cancel() - return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)) + return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, "/index.html")) }) } @@ -87,27 +91,284 @@ func PutBucketWebsite_invalid_redirect_protocol(s *S3Conf) error { }, }) cancel() - return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)) + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol)) }) } -func PutBucketWebsite_redirect_and_index(s *S3Conf) error { - testName := "PutBucketWebsite_redirect_and_index" +func PutBucketWebsite_redirectAll_index_error_routingRules(s *S3Conf) error { + testName := "PutBucketWebsite_redirectAll_index_error_routingRules" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, test := range []struct { + name string + config *types.WebsiteConfiguration + }{ + { + name: "index document", + config: &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("example.com"), + }, + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }, + }, + { + name: "error document", + config: &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("example.com"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }, + }, + { + name: "routing rules", + config: &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("example.com"), + }, + RoutingRules: []types.RoutingRule{ + { + Redirect: &types.Redirect{ + HostName: getPtr("redirect.example.com"), + }, + }, + }, + }, + }, + } { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: test.config, + }) + cancel() + if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)); err != nil { + return fmt.Errorf("%s: %w", test.name, err) + } + } + + return nil + }) +} + +func PutBucketWebsite_invalid_routing_rule_protocol(s *S3Conf) error { + testName := "PutBucketWebsite_invalid_routing_rule_protocol" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ Bucket: &bucket, WebsiteConfiguration: &types.WebsiteConfiguration{ - RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ - HostName: getPtr("example.com"), - }, IndexDocument: &types.IndexDocument{ Suffix: getPtr("index.html"), }, + RoutingRules: []types.RoutingRule{ + { + Redirect: &types.Redirect{ + HostName: getPtr("example.com"), + Protocol: types.Protocol("ftp"), + }, + }, + }, }, }) cancel() - return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)) + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol)) + }) +} + +func PutBucketWebsite_empty_error_document_key(s *S3Conf) error { + testName := "PutBucketWebsite_empty_error_document_key" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr(""), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgErrorDocumentKey, "")) + }) +} + +func PutBucketWebsite_too_many_routing_rules(s *S3Conf) error { + testName := "PutBucketWebsite_too_many_routing_rules" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + routingRules := make([]types.RoutingRule, 51) + for i := range routingRules { + routingRules[i] = types.RoutingRule{ + Condition: &types.Condition{ + KeyPrefixEquals: getPtr(fmt.Sprintf("prefix-%d/", i)), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr(fmt.Sprintf("replacement-%d/", i)), + }, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: routingRules, + }, + }) + cancel() + return checkApiErr(err, s3err.GetWebsiteRoutingRulesLimitedErr(51)) + }) +} + +func PutBucketWebsite_routing_rule_replace_key_and_prefix(s *S3Conf) error { + testName := "PutBucketWebsite_routing_rule_replace_key_and_prefix" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Redirect: &types.Redirect{ + ReplaceKeyWith: getPtr("replacement.html"), + ReplaceKeyPrefixWith: getPtr("replacement-prefix/"), + }, + }, + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrBothReplaceKeyAndPrefix)) + }) +} + +func PutBucketWebsite_invalid_http_redirect_code(s *S3Conf) error { + testName := "PutBucketWebsite_invalid_http_redirect_code" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, test := range []struct { + code string + expectedErr s3err.S3Error + }{ + {code: "300", expectedErr: s3err.GetInvalidRedirectCodeErr(300)}, + {code: "306", expectedErr: s3err.GetInvalidRedirectCodeErr(306)}, + {code: "309", expectedErr: s3err.GetInvalidRedirectCodeErr(309)}, + {code: "399", expectedErr: s3err.GetInvalidRedirectCodeErr(399)}, + {code: "jibberish", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)}, + {code: "3xx", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)}, + } { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Redirect: &types.Redirect{ + HostName: getPtr("example.com"), + HttpRedirectCode: getPtr(test.code), + }, + }, + }, + }, + }) + cancel() + if err := checkApiErr(err, test.expectedErr); err != nil { + return fmt.Errorf("code %q: %w", test.code, err) + } + } + + return nil + }) +} + +func PutBucketWebsite_invalid_http_error_code(s *S3Conf) error { + testName := "PutBucketWebsite_invalid_http_error_code" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + for _, test := range []struct { + code string + expectedErr s3err.S3Error + }{ + {code: "399", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(399)}, + {code: "418", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(418)}, + {code: "499", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(499)}, + {code: "506", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(506)}, + {code: "jibberish", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)}, + {code: "4xx", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)}, + } { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + HttpErrorCodeReturnedEquals: getPtr(test.code), + }, + Redirect: &types.Redirect{ + HostName: getPtr("example.com"), + }, + }, + }, + }, + }) + cancel() + if err := checkApiErr(err, test.expectedErr); err != nil { + return fmt.Errorf("code %q: %w", test.code, err) + } + } + + return nil + }) +} + +func PutBucketWebsite_request_too_large(s *S3Conf) error { + testName := "PutBucketWebsite_request_too_large" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + longValue := strings.Repeat("a", 2048) + routingRules := make([]types.RoutingRule, 50) + for i := range routingRules { + routingRules[i] = types.RoutingRule{ + Condition: &types.Condition{ + KeyPrefixEquals: getPtr(fmt.Sprintf("prefix-%d-%s", i, longValue)), + }, + Redirect: &types.Redirect{ + HostName: getPtr("example.com"), + ReplaceKeyWith: getPtr(fmt.Sprintf("replacement-%d-%s", i, longValue)), + HttpRedirectCode: getPtr("301"), + }, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: routingRules, + }, + }) + cancel() + return checkApiErr(err, s3err.GetMaxMessageLengthExceeded(maxWebsiteConfigSize)) }) } diff --git a/tests/integration/WebsiteHosting.go b/tests/integration/WebsiteHosting.go index 453a02b2..9d970024 100644 --- a/tests/integration/WebsiteHosting.go +++ b/tests/integration/WebsiteHosting.go @@ -15,399 +15,770 @@ package integration import ( - "bytes" - "context" - "crypto/tls" "fmt" - "io" "net/http" "strings" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" ) -// websiteHTTPClient returns an HTTP client suitable for website endpoint -// requests. It does not follow redirects and skips TLS verification -// (matching the behaviour of the S3Conf http client for self-signed certs). -func websiteHTTPClient() *http.Client { - return &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} - -// websiteGet issues a plain HTTP GET to the dedicated website endpoint. -// The bucket is resolved from the Host header. No S3 signing is applied. -func websiteGet(websiteEndpoint, host, path string) (*http.Response, error) { - url := fmt.Sprintf("%s/%s", strings.TrimRight(websiteEndpoint, "/"), strings.TrimLeft(path, "/")) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Host = host - return websiteHTTPClient().Do(req) -} - -// WebsiteHosting_error_document_served tests that when a website-enabled -// bucket has an error document configured, requesting a non-existing key -// returns the error document content with the original 404 status code. +// WebsiteHosting_error_document_served tests that a missing website object +// serves the configured error document while preserving the original 404 status. func WebsiteHosting_error_document_served(s *S3Conf) error { testName := "WebsiteHosting_error_document_served" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure website with error document - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("index.html"), - }, - ErrorDocument: &types.ErrorDocument{ - Key: getPtr("error.html"), - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), }, }) - cancel() if err != nil { return err } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } - // Upload the error document errorContent := "Custom Error Page" - ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) - _, err = s3client.PutObject(ctx, &s3.PutObjectInput{ + _, err = putObjectWithData(int64(len(errorContent)), &s3.PutObjectInput{ Bucket: &bucket, Key: getPtr("error.html"), Body: strings.NewReader(errorContent), ContentType: getPtr("text/html"), - }) - cancel() + }, s3client) if err != nil { return err } - // Request a non-existing key via plain HTTP on the website endpoint - resp, err := websiteGet(s.websiteEndpoint, bucket, "nonexistent-key") - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNotFound { - return fmt.Errorf("expected status 404, got %v", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) + resp, err := websiteGet(s, bucket, "nonexistent-key", nil) if err != nil { return err } - if string(body) != errorContent { - return fmt.Errorf("expected error document content %q, got %q", errorContent, string(body)) + if got := resp.Header.Get("Content-Type"); got != "text/html" { + return fmt.Errorf("expected text/html Content-Type, got %q", got) } - - return nil + return checkWebsiteResponse(resp, http.StatusNotFound, []byte(errorContent)) }) } -// WebsiteHosting_error_document_not_found tests that when the configured -// error document itself does not exist, a 404 error page is returned. +// WebsiteHosting_error_document_not_found tests that a missing configured +// error document returns the complete website NoSuchKey error response. func WebsiteHosting_error_document_not_found(s *S3Conf) error { testName := "WebsiteHosting_error_document_not_found" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure website with error document (but don't upload it) - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("index.html"), - }, - ErrorDocument: &types.ErrorDocument{ - Key: getPtr("error.html"), - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), }, }) - cancel() + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "nonexistent-key", nil) if err != nil { return err } - // Request a non-existing key - should get 404 since error doc doesn't exist either - resp, err := websiteGet(s.websiteEndpoint, bucket, "nonexistent-key") - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNotFound { - return fmt.Errorf("expected status 404, got %v", resp.StatusCode) - } - - return nil + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoSuchKey)) }) } -// WebsiteHosting_no_error_document tests that when website is enabled -// but no error document is configured, a 404 error page is returned. +// WebsiteHosting_no_error_document tests that a website bucket without an +// error document returns the complete website NoSuchKey error response. func WebsiteHosting_no_error_document(s *S3Conf) error { testName := "WebsiteHosting_no_error_document" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure website without error document - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("index.html"), - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), }, }) - cancel() + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "nonexistent-key", nil) if err != nil { return err } - // Request a non-existing key - should get 404 - resp, err := websiteGet(s.websiteEndpoint, bucket, "nonexistent-key") + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoSuchKey)) + }) +} + +// WebsiteHosting_private_object_and_error_document tests that website hosting +// does not serve either the requested object or the configured error document +// unless public object access has been granted. +func WebsiteHosting_private_object_and_error_document(s *S3Conf) error { + testName := "WebsiteHosting_private_object_and_error_document" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }) if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNotFound { - return fmt.Errorf("expected status 404, got %v", resp.StatusCode) + privateError := "private error" + _, err = putObjectWithData(int64(len(privateError)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(privateError), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err } - return nil + resp, err := websiteGet(s, bucket, "private.html", nil) + if err != nil { + return err + } + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrAccessDenied)) }) } // WebsiteHosting_routing_rule_post_request_redirect tests that a post-request -// routing rule (matching on error code) issues a redirect instead of serving -// the error or error document. +// routing rule matching a 404 issues a redirect instead of serving an error. func WebsiteHosting_routing_rule_post_request_redirect(s *S3Conf) error { testName := "WebsiteHosting_routing_rule_post_request_redirect" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure website with a post-request routing rule for 404 - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("index.html"), - }, - ErrorDocument: &types.ErrorDocument{ - Key: getPtr("error.html"), - }, - RoutingRules: []types.RoutingRule{ - { - Condition: &types.Condition{ - HttpErrorCodeReturnedEquals: getPtr("404"), - }, - Redirect: &types.Redirect{ - HostName: getPtr("fallback.example.com"), - ReplaceKeyWith: getPtr("not-found"), - HttpRedirectCode: getPtr("302"), - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + HostName: getPtr("fallback.example.com"), + ReplaceKeyWith: getPtr("not-found"), + HttpRedirectCode: getPtr("302"), }, }, }, }) - cancel() + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "missing-page", nil) if err != nil { return err } - // Request a non-existing key via the website endpoint - resp, err := websiteGet(s.websiteEndpoint, bucket, "missing-page") + wantLocation, err := websiteAbsoluteURL(s, "fallback.example.com", "not-found") if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusFound { - return fmt.Errorf("expected status 302, got %v", resp.StatusCode) + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) } - - location := resp.Header.Get("Location") - if location == "" { - return fmt.Errorf("expected Location header, got none") - } - - // The redirect should point to fallback.example.com/not-found - if !strings.Contains(location, "fallback.example.com") || !strings.Contains(location, "not-found") { - return fmt.Errorf("expected redirect to fallback.example.com/not-found, got %q", location) - } - - return nil + return checkWebsiteResponse(resp, http.StatusFound, []byte(http.StatusText(http.StatusFound))) }) } -// WebsiteHosting_routing_rule_pre_request_redirect tests that a pre-request -// routing rule (matching on key prefix only) issues a redirect before the -// object is fetched. +// WebsiteHosting_routing_rule_pre_request_redirect tests that a key-prefix +// routing rule redirects before public access or object existence is checked. func WebsiteHosting_routing_rule_pre_request_redirect(s *S3Conf) error { testName := "WebsiteHosting_routing_rule_pre_request_redirect" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure website with a pre-request routing rule - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("index.html"), - }, - RoutingRules: []types.RoutingRule{ - { - Condition: &types.Condition{ - KeyPrefixEquals: getPtr("old-docs/"), - }, - Redirect: &types.Redirect{ - ReplaceKeyPrefixWith: getPtr("new-docs/"), - HttpRedirectCode: getPtr("301"), - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("old-docs/"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("new-docs/"), + HttpRedirectCode: getPtr("301"), }, }, }, }) - cancel() if err != nil { return err } - // Request old-docs/page.html via the website endpoint - resp, err := websiteGet(s.websiteEndpoint, bucket, "old-docs/page.html") + resp, err := websiteGet(s, bucket, "old-docs/page.html", nil) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusMovedPermanently { - return fmt.Errorf("expected status 301, got %v", resp.StatusCode) + wantLocation, err := websiteURL(s, bucket, "new-docs/page.html") + if err != nil { + return err } - - location := resp.Header.Get("Location") - if location == "" { - return fmt.Errorf("expected Location header, got none") + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) } - - // The redirect should rewrite old-docs/ -> new-docs/ - if !strings.Contains(location, "new-docs/page.html") { - return fmt.Errorf("expected redirect to contain new-docs/page.html, got %q", location) - } - - return nil + return checkWebsiteResponse(resp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))) }) } -// WebsiteHosting_redirect_all_requests tests the RedirectAllRequestsTo -// configuration, which should redirect any request to the specified host. +// WebsiteHosting_routing_rule_prefix_and_error_redirect tests a routing rule +// with both KeyPrefixEquals and HttpErrorCodeReturnedEquals conditions. +func WebsiteHosting_routing_rule_prefix_and_error_redirect(s *S3Conf) error { + testName := "WebsiteHosting_routing_rule_prefix_and_error_redirect" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("old/"), + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("archived/"), + HttpRedirectCode: getPtr("307"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "old/missing.html?ref=1", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + wantLocation, err := websiteURL(s, bucket, "archived/missing.html?ref=1") + if err != nil { + return err + } + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) + } + return checkWebsiteResponse(resp, http.StatusTemporaryRedirect, []byte(http.StatusText(http.StatusTemporaryRedirect))) + }) +} + +// WebsiteHosting_routing_rule_no_match_serves_error_document tests that routing +// rules which do not match fall back to the configured error document. +func WebsiteHosting_routing_rule_no_match_serves_error_document(s *S3Conf) error { + testName := "WebsiteHosting_routing_rule_no_match_serves_error_document" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("docs/"), + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("archive/"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + errorContent := "fallback error" + _, err = putObjectWithData(int64(len(errorContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(errorContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "images/missing.png", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkWebsiteResponse(resp, http.StatusNotFound, []byte(errorContent)) + }) +} + +// WebsiteHosting_redirect_all_requests tests RedirectAllRequestsTo, including +// path and query preservation, without requiring public object access. func WebsiteHosting_redirect_all_requests(s *S3Conf) error { testName := "WebsiteHosting_redirect_all_requests" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure redirect-all - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ - HostName: getPtr("www.example.com"), - Protocol: types.ProtocolHttps, - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("www.example.com"), + Protocol: types.ProtocolHttps, }, }) - cancel() if err != nil { return err } - // Request any path via the website endpoint - resp, err := websiteGet(s.websiteEndpoint, bucket, "any/path/here") + resp, err := websiteGet(s, bucket, "any/path/here?tracking=1", nil) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusMovedPermanently { - return fmt.Errorf("expected status 301, got %v", resp.StatusCode) + if got, want := resp.Header.Get("Location"), "https://www.example.com/any/path/here?tracking=1"; got != want { + return fmt.Errorf("expected Location %q, got %q", want, got) } - - location := resp.Header.Get("Location") - if !strings.HasPrefix(location, "https://www.example.com/") { - return fmt.Errorf("expected redirect to https://www.example.com/, got %q", location) - } - - if !strings.Contains(location, "any/path/here") { - return fmt.Errorf("expected redirect to preserve path, got %q", location) - } - - return nil + return checkWebsiteResponse(resp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))) }) } -// WebsiteHosting_index_document tests that requesting a directory-like -// path on a website-enabled bucket serves the index document. +// WebsiteHosting_index_document tests root and directory-style index document +// resolution through the website endpoint. func WebsiteHosting_index_document(s *S3Conf) error { testName := "WebsiteHosting_index_document" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - // Configure website - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("index.html"), - }, + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), }, }) - cancel() if err != nil { return err } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } - // Upload index document at root indexContent := "Welcome" - ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) - _, err = s3client.PutObject(ctx, &s3.PutObjectInput{ + _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{ Bucket: &bucket, Key: getPtr("index.html"), Body: strings.NewReader(indexContent), ContentType: getPtr("text/html"), - }) - cancel() + }, s3client) + if err != nil { + return err + } + docsContent := "Docs Home" + _, err = putObjectWithData(int64(len(docsContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("docs/index.html"), + Body: strings.NewReader(docsContent), + ContentType: getPtr("text/html"), + }, s3client) if err != nil { return err } - // Request the root path via the website endpoint - resp, err := websiteGet(s.websiteEndpoint, bucket, "/") - if err != nil { - return err - } - defer resp.Body.Close() + for _, test := range []struct { + path string + body string + }{ + {"/", indexContent}, + {"docs/", docsContent}, + } { + resp, err := websiteGet(s, bucket, test.path, nil) + if err != nil { + return err + } - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("expected status 200, got %v; body: %s", resp.StatusCode, body) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - if !bytes.Equal(body, []byte(indexContent)) { - return fmt.Errorf("expected index document content %q, got %q", indexContent, string(body)) + err = checkWebsiteResponse(resp, http.StatusOK, []byte(test.body)) + resp.Body.Close() + if err != nil { + return fmt.Errorf("%s: %w", test.path, err) + } } return nil }) } + +// WebsiteHosting_index_error_document_and_routing_rules covers a combined +// website configuration with index, error document, pre-rule, and post-rule. +func WebsiteHosting_index_error_document_and_routing_rules(s *S3Conf) error { + testName := "WebsiteHosting_index_error_document_and_routing_rules" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("legacy/"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("docs/"), + HttpRedirectCode: getPtr("301"), + }, + }, + { + Condition: &types.Condition{ + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + HostName: getPtr("fallback.example.com"), + ReplaceKeyWith: getPtr("missing"), + HttpRedirectCode: getPtr("302"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + indexContent := "combined index" + _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("index.html"), + Body: strings.NewReader(indexContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + combinedError := "combined error" + _, err = putObjectWithData(int64(len(combinedError)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(combinedError), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + indexResp, err := websiteGet(s, bucket, "/", nil) + if err != nil { + return err + } + if err := checkWebsiteResponse(indexResp, http.StatusOK, []byte(indexContent)); err != nil { + return err + } + indexResp.Body.Close() + + preResp, err := websiteGet(s, bucket, "legacy/page.html", nil) + if err != nil { + return err + } + wantPreLocation, err := websiteURL(s, bucket, "docs/page.html") + if err != nil { + preResp.Body.Close() + return err + } + if got := preResp.Header.Get("Location"); got != wantPreLocation { + preResp.Body.Close() + return fmt.Errorf("expected pre-rule Location %q, got %q", wantPreLocation, got) + } + if err := checkWebsiteResponse(preResp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))); err != nil { + return err + } + + postResp, err := websiteGet(s, bucket, "unknown.html", nil) + if err != nil { + return err + } + wantPostLocation, err := websiteAbsoluteURL(s, "fallback.example.com", "missing") + if err != nil { + postResp.Body.Close() + return err + } + if got := postResp.Header.Get("Location"); got != wantPostLocation { + postResp.Body.Close() + return fmt.Errorf("expected post-rule Location %q, got %q", wantPostLocation, got) + } + if err := checkWebsiteResponse(postResp, http.StatusFound, []byte(http.StatusText(http.StatusFound))); err != nil { + return err + } + + return nil + }) +} + +func WebsiteHosting_options_preflight_access_granted(s *S3Conf) error { + testName := "WebsiteHosting_options_preflight_access_granted" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"https://client.example"}, + AllowedMethods: []string{http.MethodGet, http.MethodHead}, + AllowedHeaders: []string{"Content-Type", "X-Amz-Date"}, + ExposeHeaders: []string{"Content-Length"}, + MaxAgeSeconds: getPtr(int32(42)), + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteOptions(s, bucket, "index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + "Access-Control-Request-Headers": "content-type, X-Amz-Date", + }) + if err != nil { + return err + } + + corsHeaders, err := extractCORSHeaders(resp) + if err != nil { + return err + } + if err := comparePreflightResult(&PreflightResult{ + Origin: "https://client.example", + Methods: "GET, HEAD", + AllowHeaders: "content-type, x-amz-date", + ExposeHeaders: "Content-Length, ETag", + MaxAge: "42", + AllowCredentials: "true", + Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + }, corsHeaders); err != nil { + return err + } + + return checkWebsiteResponse(resp, http.StatusOK, nil) + }) +} + +func WebsiteHosting_get_cors_headers(s *S3Conf) error { + testName := "WebsiteHosting_get_cors_headers" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + indexContent := "CORS GET" + _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("index.html"), + Body: strings.NewReader(indexContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + maxAge := int32(42) + err = putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"https://client.example"}, + AllowedMethods: []string{http.MethodGet, http.MethodHead}, + ExposeHeaders: []string{"Content-Length"}, + MaxAgeSeconds: &maxAge, + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "/", map[string]string{ + "Origin": "https://client.example", + }) + if err != nil { + return err + } + + corsHeaders, err := extractCORSHeaders(resp) + if err != nil { + resp.Body.Close() + return err + } + if err := comparePreflightResult(&PreflightResult{ + Origin: "https://client.example", + Methods: "GET, HEAD", + ExposeHeaders: "Content-Length, ETag, x-amz-storage-class", + MaxAge: "42", + AllowCredentials: "true", + Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + }, corsHeaders); err != nil { + resp.Body.Close() + return err + } + + return checkWebsiteResponse(resp, http.StatusOK, []byte(indexContent)) + }) +} + +func WebsiteHosting_head_cors_headers(s *S3Conf) error { + testName := "WebsiteHosting_head_cors_headers" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + headContent := "CORS HEAD" + _, err = putObjectWithData(int64(len(headContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("head.html"), + Body: strings.NewReader(headContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + err = putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{http.MethodHead}, + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteHead(s, bucket, "head.html", map[string]string{ + "Origin": "https://client.example", + }) + if err != nil { + return err + } + + corsHeaders, err := extractCORSHeaders(resp) + if err != nil { + resp.Body.Close() + return err + } + if err := comparePreflightResult(&PreflightResult{ + Origin: "*", + Methods: "HEAD", + ExposeHeaders: "ETag, x-amz-storage-class", + AllowCredentials: "false", + Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + }, corsHeaders); err != nil { + resp.Body.Close() + return err + } + + return checkWebsiteResponse(resp, http.StatusOK, nil) + }) +} + +func WebsiteHosting_options_preflight_access_forbidden(s *S3Conf) error { + testName := "WebsiteHosting_options_preflight_access_forbidden" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"https://client.example"}, + AllowedMethods: []string{http.MethodHead}, + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteOptions(s, bucket, "index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + }) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkWebsiteErrorResponse(resp, + s3err.GetAccessForbiddenErr(s3err.ErrCORSForbidden, http.MethodOptions, s3err.ResourceTypeObject)) + }) +} + +func WebsiteHosting_options_preflight_missing_origin(s *S3Conf) error { + testName := "WebsiteHosting_options_preflight_missing_origin" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + resp, err := websiteOptions(s, bucket, "index.html", map[string]string{ + "Access-Control-Request-Method": http.MethodGet, + }) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrMissingCORSOrigin)) + }) +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 323b20fe..b93e2e94 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -14,8 +14,6 @@ package integration -import "fmt" - func TestAuthentication(ts *TestState) { ts.Run(Authentication_invalid_auth_header) ts.Run(Authentication_unsupported_signature_version) @@ -670,7 +668,14 @@ func TestPutBucketWebsite(ts *TestState) { ts.Run(PutBucketWebsite_empty_suffix) ts.Run(PutBucketWebsite_suffix_with_slash) ts.Run(PutBucketWebsite_invalid_redirect_protocol) - ts.Run(PutBucketWebsite_redirect_and_index) + ts.Run(PutBucketWebsite_redirectAll_index_error_routingRules) + ts.Run(PutBucketWebsite_invalid_routing_rule_protocol) + ts.Run(PutBucketWebsite_empty_error_document_key) + ts.Run(PutBucketWebsite_too_many_routing_rules) + ts.Run(PutBucketWebsite_routing_rule_replace_key_and_prefix) + ts.Run(PutBucketWebsite_invalid_http_redirect_code) + ts.Run(PutBucketWebsite_invalid_http_error_code) + ts.Run(PutBucketWebsite_request_too_large) ts.Run(PutBucketWebsite_success) ts.Run(PutBucketWebsite_success_redirect_all) } @@ -688,17 +693,22 @@ func TestDeleteBucketWebsite(ts *TestState) { } func TestWebsiteHosting(ts *TestState) { - if ts.conf.websiteEndpoint == "" { - fmt.Println("skipping TestWebsiteHosting: no website endpoint configured") - return - } ts.Run(WebsiteHosting_error_document_served) ts.Run(WebsiteHosting_error_document_not_found) ts.Run(WebsiteHosting_no_error_document) + ts.Run(WebsiteHosting_private_object_and_error_document) ts.Run(WebsiteHosting_routing_rule_post_request_redirect) ts.Run(WebsiteHosting_routing_rule_pre_request_redirect) + ts.Run(WebsiteHosting_routing_rule_prefix_and_error_redirect) + ts.Run(WebsiteHosting_routing_rule_no_match_serves_error_document) ts.Run(WebsiteHosting_redirect_all_requests) ts.Run(WebsiteHosting_index_document) + ts.Run(WebsiteHosting_index_error_document_and_routing_rules) + ts.Run(WebsiteHosting_get_cors_headers) + ts.Run(WebsiteHosting_head_cors_headers) + ts.Run(WebsiteHosting_options_preflight_access_granted) + ts.Run(WebsiteHosting_options_preflight_access_forbidden) + ts.Run(WebsiteHosting_options_preflight_missing_origin) } func TestPreflightOPTIONSEndpoint(ts *TestState) { @@ -899,7 +909,6 @@ func TestFullFlow(ts *TestState) { TestPutBucketWebsite(ts) TestGetBucketWebsite(ts) TestDeleteBucketWebsite(ts) - TestWebsiteHosting(ts) TestPreflightOPTIONSEndpoint(ts) TestPutObjectLockConfiguration(ts) TestGetObjectLockConfiguration(ts) @@ -1802,7 +1811,14 @@ func GetIntTests() IntTests { "PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix, "PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash, "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, - "PutBucketWebsite_redirect_and_index": PutBucketWebsite_redirect_and_index, + "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, + "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, + "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key, + "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules, + "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix, + "PutBucketWebsite_invalid_http_redirect_code": PutBucketWebsite_invalid_http_redirect_code, + "PutBucketWebsite_invalid_http_error_code": PutBucketWebsite_invalid_http_error_code, + "PutBucketWebsite_request_too_large": PutBucketWebsite_request_too_large, "PutBucketWebsite_success": PutBucketWebsite_success, "PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all, "GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket, @@ -1814,10 +1830,19 @@ func GetIntTests() IntTests { "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, + "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document, "WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, + "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect, + "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document, "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, "WebsiteHosting_index_document": WebsiteHosting_index_document, + "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules, + "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers, + "WebsiteHosting_head_cors_headers": WebsiteHosting_head_cors_headers, + "WebsiteHosting_options_preflight_access_granted": WebsiteHosting_options_preflight_access_granted, + "WebsiteHosting_options_preflight_access_forbidden": WebsiteHosting_options_preflight_access_forbidden, + "WebsiteHosting_options_preflight_missing_origin": WebsiteHosting_options_preflight_missing_origin, "PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket, "PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin, "PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method, diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go index 6382f07a..9b7ad8e0 100644 --- a/tests/integration/s3conf.go +++ b/tests/integration/s3conf.go @@ -36,7 +36,9 @@ type S3Conf struct { awsSecret string awsRegion string endpoint string - websiteEndpoint string + websiteScheme string + websiteDomain string + websitePort string hostStyle bool checksumDisable bool PartSize int64 @@ -65,6 +67,9 @@ func NewS3Conf(opts ...Option) *S3Conf { customHTTPClient := &http.Client{ Transport: customTransport, Timeout: shortTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, } s.httpClient = customHTTPClient @@ -86,8 +91,14 @@ func WithRegion(r string) Option { func WithEndpoint(e string) Option { return func(s *S3Conf) { s.endpoint = e } } -func WithWebsiteEndpoint(e string) Option { - return func(s *S3Conf) { s.websiteEndpoint = e } +func WithWebsiteScheme(scheme string) Option { + return func(s *S3Conf) { s.websiteScheme = scheme } +} +func WithWebsiteDomain(d string) Option { + return func(s *S3Conf) { s.websiteDomain = d } +} +func WithWebsitePort(p string) Option { + return func(s *S3Conf) { s.websitePort = p } } func WithDisableChecksum() Option { return func(s *S3Conf) { s.checksumDisable = true } diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 09a29b44..83b34c29 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -452,6 +452,147 @@ func checkHTTPResponseApiErr(resp *http.Response, expected s3err.S3Error) error return compareS3ApiError(expected, &errResp) } +// websiteGet issues a plain HTTP GET to the dedicated website endpoint. +// The bucket is resolved from the request URL host. No S3 signing is applied. +func websiteGet(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) { + return websiteRequest(s, http.MethodGet, bucket, path, headers) +} + +func websiteHead(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) { + return websiteRequest(s, http.MethodHead, bucket, path, headers) +} + +func websiteOptions(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) { + return websiteRequest(s, http.MethodOptions, bucket, path, headers) +} + +func websiteRequest(s *S3Conf, method, bucket, path string, headers map[string]string) (*http.Response, error) { + reqURL, err := websiteURL(s, bucket, path) + if err != nil { + return nil, err + } + req, err := http.NewRequest(method, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create website request: %w", err) + } + for key, val := range headers { + req.Header.Set(key, val) + } + + return s.httpClient.Do(req) +} + +func websiteHost(s *S3Conf, bucket string) string { + _, domain, port := websiteEndpointParts(s) + + host := fmt.Sprintf("%s.%s", bucket, domain) + if port != "" { + host = fmt.Sprintf("%s:%s", host, port) + } + return host +} + +func websiteURL(s *S3Conf, bucket, path string) (string, error) { + return websiteAbsoluteURL(s, websiteHost(s, bucket), path) +} + +func websiteAbsoluteURL(s *S3Conf, host, path string) (string, error) { + scheme, _, _ := websiteEndpointParts(s) + + rel, err := url.Parse("/" + strings.TrimLeft(path, "/")) + if err != nil { + return "", fmt.Errorf("parse website request path: %w", err) + } + + return (&url.URL{ + Scheme: scheme, + Host: host, + Path: rel.Path, + RawQuery: rel.RawQuery, + }).String(), nil +} + +func websiteEndpointParts(s *S3Conf) (scheme, domain, port string) { + scheme = strings.ToLower(strings.TrimSpace(s.websiteScheme)) + domain = strings.TrimSpace(s.websiteDomain) + port = strings.TrimPrefix(strings.TrimSpace(s.websitePort), ":") + + return scheme, domain, port +} + +func putBucketWebsiteConfig(client *s3.Client, bucket string, config *types.WebsiteConfiguration) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: config, + }) + cancel() + return err +} + +func checkWebsiteResponse(resp *http.Response, expectedStatus int, expectedBody []byte) error { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read website response body: %w", err) + } + + if resp.StatusCode != expectedStatus { + return fmt.Errorf("expected status %v, got %v; body: %s", expectedStatus, resp.StatusCode, body) + } + + return compareBodySHA256(expectedBody, body) +} + +func checkWebsiteErrorResponse(resp *http.Response, expected s3err.S3Error) error { + apiErr := expected.BaseError() + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read website error body: %w", err) + } + + if resp.StatusCode != apiErr.HTTPStatusCode { + return fmt.Errorf("expected status %v, got %v; body: %s", apiErr.HTTPStatusCode, resp.StatusCode, body) + } + if got := resp.Header.Get("x-amz-error-code"); got != apiErr.Code { + return fmt.Errorf("expected x-amz-error-code %q, got %q", apiErr.Code, got) + } + if got := resp.Header.Get("x-amz-error-message"); got != apiErr.Description { + return fmt.Errorf("expected x-amz-error-message %q, got %q", apiErr.Description, got) + } + requestID := resp.Header.Get("x-amz-request-id") + if requestID == "" { + return fmt.Errorf("expected x-amz-request-id header") + } + hostID := resp.Header.Get("x-amz-id-2") + if hostID == "" { + return fmt.Errorf("expected x-amz-id-2 header") + } + if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "text/html") { + return fmt.Errorf("expected html Content-Type, got %q", got) + } + if methodErr, ok := expected.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 { + if got, want := resp.Header.Get("Allow"), methodErr.AllowedMethodsString(); got != want { + return fmt.Errorf("expected Allow header %q, got %q", want, got) + } + } + + expectedBody := expected.HTMLBody(requestID, hostID) + return compareBodySHA256(expectedBody, body) +} + +func compareBodySHA256(expected, actual []byte) error { + expectedSum := sha256.Sum256(expected) + actualSum := sha256.Sum256(actual) + if expectedSum != actualSum { + return fmt.Errorf("body checksum mismatch: expected sha256 %x, got %x; expected body %q, got %q", + expectedSum, actualSum, string(expected), string(actual)) + } + + return nil +} + func compareS3ApiError(expected s3err.S3Error, received *APIErrorResponse) error { apiErr := expected.BaseError() if received == nil { diff --git a/tests/test_rest_not_implemented.sh b/tests/test_rest_not_implemented.sh index 2604add5..3366bc44 100755 --- a/tests/test_rest_not_implemented.sh +++ b/tests/test_rest_not_implemented.sh @@ -162,21 +162,6 @@ source ./tests/setup.sh assert_success } -@test "REST - GetBucketWebsite" { - run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "GET" - assert_success -} - -@test "REST - PutBucketWebsite" { - run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "PUT" - assert_success -} - -@test "REST - DeleteBucketWebsite" { - run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "DELETE" - assert_success -} - @test "REST - GetPublicAccessBlock" { run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "publicAccessBlock=" "GET" assert_success diff --git a/tests/website-hosting-tests/dnsmasq.conf b/tests/website-hosting-tests/dnsmasq.conf new file mode 100644 index 00000000..5d7054b9 --- /dev/null +++ b/tests/website-hosting-tests/dnsmasq.conf @@ -0,0 +1,2 @@ +address=/.dev/10.89.0.10 +no-resolv diff --git a/tests/website-hosting-tests/docker-compose.yml b/tests/website-hosting-tests/docker-compose.yml new file mode 100644 index 00000000..637cb207 --- /dev/null +++ b/tests/website-hosting-tests/docker-compose.yml @@ -0,0 +1,58 @@ +services: + dnsmasq: + image: strm/dnsmasq + container_name: website-dns-resolver + restart: on-failure + volumes: + - './dnsmasq.conf:/etc/dnsmasq.conf' + cap_add: + - NET_ADMIN + healthcheck: + test: 'if [ -z "$(netstat -nltu |grep \:53)" ]; then exit 1;else exit 0;fi' + interval: 2s + timeout: 2s + retries: 20 + networks: + devnet: + ipv4_address: 10.89.0.53 + + server: + build: + context: ../.. + dockerfile: tests/host-style-tests/Dockerfile + depends_on: + dnsmasq: + condition: service_healthy + command: ["-a", "user", "-s", "pass", "--health", "/health", "--iam-dir", "/tmp/vgw", "--website", ":8080", "--website-domain", "dev", "--website-no-tls", "posix", "/tmp/vgw"] + dns: + - 10.89.0.53 + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7070/health"] + interval: 2s + timeout: 2s + retries: 20 + networks: + devnet: + ipv4_address: 10.89.0.10 + + test: + build: + context: ../.. + dockerfile: tests/host-style-tests/Dockerfile + depends_on: + server: + condition: service_healthy + dnsmasq: + condition: service_healthy + command: ["test", "-a", "user", "-s", "pass", "-e", "http://10.89.0.10:7070", "website-hosting", "--scheme", "http", "--domain", "dev", "--port", "8080"] + dns: + - 10.89.0.53 + networks: + devnet: + +networks: + devnet: + driver: bridge + ipam: + config: + - subnet: 10.89.0.0/16 diff --git a/website/handler.go b/website/handler.go index 463ceec7..506a6f2f 100644 --- a/website/handler.go +++ b/website/handler.go @@ -15,9 +15,8 @@ package website import ( - "encoding/xml" + "errors" "fmt" - "html" "io" "net/http" "strconv" @@ -25,11 +24,25 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gofiber/fiber/v2" + "github.com/versity/versitygw/auth" "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/s3api/middlewares" + "github.com/versity/versitygw/s3api/utils" + "github.com/versity/versitygw/s3err" "github.com/versity/versitygw/s3response" ) -// newHandler returns a fiber handler that serves static website content. +var websiteAllowedMethods = []string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions} + +type websiteController struct { + be backend.Backend + domain string + domainSuffix string + applyCORS fiber.Handler +} + +// newWebsiteController returns a controller that serves static website content. // It resolves the bucket name from the Host header using the configured domain, // fetches the website configuration, and serves objects accordingly. // @@ -40,95 +53,202 @@ import ( // Catch-all mode (--website-domain omitted or empty): // - Host "blog.example.com" -> bucket "blog.example.com" // - Host "mysite.org" -> bucket "mysite.org" -func newHandler(be backend.Backend, domain string) fiber.Handler { - // Pre-compute the domain suffix for subdomain extraction. - // Given domain "example.com", we look for ".example.com" suffix. - domainSuffix := "." + domain +func newWebsiteController(be backend.Backend, domain string) *websiteController { + controller := &websiteController{ + be: be, + domain: domain, + domainSuffix: "." + domain, + } + controller.applyCORS = middlewares.ApplyBucketCORS(be, controller.resolveBucket, "") + return controller +} - return func(ctx *fiber.Ctx) error { - host := ctx.Hostname() - if host == "" { - return sendError(ctx, http.StatusBadRequest, "Bad Request", "Missing Host header") +func (c *websiteController) Get(ctx *fiber.Ctx) error { + return c.serve(ctx, c.getObject) +} + +func (c *websiteController) Head(ctx *fiber.Ctx) error { + return c.serve(ctx, c.headObject) +} + +func (c *websiteController) Options(ctx *fiber.Ctx) error { + bucket, err := c.resolveBucket(ctx) + if err != nil { + return sendError(ctx, err) + } + + origin := ctx.Get("Origin") + method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method")) + headers := ctx.Get("Access-Control-Request-Headers") + + if origin == "" { + debuglogger.Logf("origin is missing: %v", origin) + return sendError(ctx, s3err.GetAPIError(s3err.ErrMissingCORSOrigin)) + } + + if !method.IsValid() { + debuglogger.Logf("invalid cors method: %s", method) + return sendError(ctx, s3err.GetInvalidCORSMethodErr(method.String())) + } + + parsedHeaders, err := auth.ParseCORSHeaders(headers) + if err != nil { + return sendError(ctx, err) + } + + cors, err := c.be.GetBucketCors(ctx.Context(), bucket) + if err != nil { + debuglogger.Logf("failed to get bucket cors: %v", err) + if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)) { + err = s3err.GetAccessForbiddenErr(s3err.ErrCORSIsNotEnabled, http.MethodOptions, s3err.ResourceTypeBucket) + debuglogger.Logf("bucket cors is not set: %v", err) } + return sendError(ctx, err) + } - // Strip port from host if present - if idx := strings.LastIndex(host, ":"); idx != -1 { - // Be careful with IPv6: only strip if it's not inside brackets - if !strings.Contains(host[idx:], "]") { - host = host[:idx] - } - } + corsConfig, err := auth.ParseCORSOutput(cors) + if err != nil { + return sendError(ctx, err) + } - // Resolve bucket name from host - bucket := resolveBucket(host, domain, domainSuffix) - if bucket == "" { - return sendError(ctx, http.StatusForbidden, "Forbidden", - fmt.Sprintf("No bucket could be resolved from host %q", html.EscapeString(ctx.Hostname()))) - } + allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders, s3err.ResourceTypeObject) + if err != nil { + debuglogger.Logf("cors access forbidden: %v", err) + return sendError(ctx, err) + } - // Fetch website configuration - data, err := be.GetBucketWebsite(ctx.Context(), bucket) - if err != nil { - return sendError(ctx, http.StatusNotFound, "Not Found", - fmt.Sprintf("No website configuration for bucket %q", bucket)) - } + setCORSPreflightHeaders(ctx, allowConfig) + ctx.Status(http.StatusOK) + return nil +} - var config s3response.WebsiteConfiguration - if xmlErr := xml.Unmarshal(data, &config); xmlErr != nil { - return sendError(ctx, http.StatusInternalServerError, "Internal Server Error", - "Invalid website configuration") - } +func registerWebsiteRoutes(app *fiber.App, be backend.Backend, domain string) { + controller := newWebsiteController(be, domain) - key := strings.TrimPrefix(ctx.Path(), "/") + app.Head("*", controller.Head) + app.Get("*", controller.Get) + app.Options("*", controller.Options) + app.All("*", controller.MethodNotAllowed) +} - // Handle RedirectAllRequestsTo - if config.RedirectAllRequestsTo != nil { - return handleRedirectAll(ctx, config.RedirectAllRequestsTo, key) - } - - // Evaluate pre-request routing rules - if rule := config.MatchPreRequestRule(key); rule != nil { - return applyRedirect(ctx, &rule.Redirect, rule.Condition, key) - } - - // Rewrite directory-like keys to include index document suffix - if config.IndexDocument != nil && config.IndexDocument.Suffix != "" { - if key == "" || strings.HasSuffix(key, "/") { - key = key + config.IndexDocument.Suffix - } - } - - // Fetch the object - emptyRange := "" - result, getErr := be.GetObject(ctx.Context(), &s3.GetObjectInput{ - Bucket: &bucket, - Key: &key, - Range: &emptyRange, - }) - if getErr == nil && result.Body != nil { - defer result.Body.Close() - return serveObject(ctx, result, key) - } - - // Object not found (or other error) — evaluate post-request routing rules - httpErrCode := http.StatusNotFound - errorCode := strconv.Itoa(httpErrCode) - - if rule := config.MatchPostRequestRule(key, errorCode); rule != nil { - return applyRedirect(ctx, &rule.Redirect, rule.Condition, key) - } - - // Serve error document if configured - if config.ErrorDocument != nil && config.ErrorDocument.Key != "" { - return serveErrorDocument(ctx, be, bucket, config.ErrorDocument.Key, httpErrCode) - } - - return sendError(ctx, http.StatusNotFound, "Not Found", - fmt.Sprintf("The specified key %q does not exist", key)) +func setCORSPreflightHeaders(ctx *fiber.Ctx, allowConfig *auth.CORSAllowanceConfig) { + ctx.Set("Access-Control-Allow-Origin", allowConfig.Origin) + ctx.Set("Access-Control-Allow-Methods", allowConfig.Methods) + ctx.Set("Access-Control-Expose-Headers", corsExposeHeaders(allowConfig.ExposedHeaders)) + ctx.Set("Access-Control-Allow-Credentials", allowConfig.AllowCredentials) + ctx.Set("Access-Control-Allow-Headers", allowConfig.AllowHeaders) + ctx.Set("Vary", middlewares.VaryHdr) + if allowConfig.MaxAge != nil { + ctx.Set("Access-Control-Max-Age", strconv.Itoa(int(*allowConfig.MaxAge))) } } -// resolveBucket extracts the bucket name from the host header. +func corsExposeHeaders(exposed string) string { + exposed = strings.TrimSpace(exposed) + if exposed == "" { + return "ETag" + } + if exposed == "*" { + return exposed + } + + for part := range strings.SplitSeq(exposed, ",") { + if strings.EqualFold(strings.TrimSpace(part), "ETag") { + return exposed + } + } + + return exposed + ", ETag" +} + +func (c *websiteController) MethodNotAllowed(ctx *fiber.Ctx) error { + return sendError(ctx, s3err.GetMethodNotAllowedErr(ctx.Method(), s3err.ResourceTypeObject, websiteAllowedMethods)) +} + +type websiteRequestInfo struct { + bucket string + config *s3response.WebsiteConfiguration + key string +} + +type websiteObjectReader func(ctx *fiber.Ctx, bucket, key string) websiteResult + +func (c *websiteController) serve(ctx *fiber.Ctx, readObject websiteObjectReader) error { + req, err := c.resolveRequest(ctx) + if err != nil { + return sendError(ctx, err) + } + + if err := c.applyCORS(ctx); err != nil { + return sendError(ctx, err) + } + + if req.config.RedirectAllRequestsTo != nil { + return handleRedirectAll(ctx, req.config.RedirectAllRequestsTo, req.key) + } + + if rule := req.config.MatchPrefetchRoutingRule(req.key); rule != nil { + return applyRedirect(ctx, &rule.Redirect, rule.Condition, req.key) + } + + resolvedKey := resolveIndexKey(req.key, req.config) + result := readObject(ctx, req.bucket, resolvedKey) + if result.Err == nil { + return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject) + } + if result.StatusCode >= http.StatusInternalServerError { + return sendError(ctx, result.Err) + } + + if rule := req.config.MatchPostErrorRoutingRule(req.key, result.StatusCode); rule != nil { + return applyRedirect(ctx, &rule.Redirect, rule.Condition, req.key) + } + + return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject) +} + +func (c *websiteController) resolveRequest(ctx *fiber.Ctx) (*websiteRequestInfo, error) { + bucket, err := c.resolveBucket(ctx) + if err != nil { + return nil, err + } + + key := strings.TrimPrefix(ctx.Path(), "/") + if err := validateWebsiteNames(bucket, key); err != nil { + return nil, err + } + + data, err := c.be.GetBucketWebsite(ctx.Context(), bucket) + if err != nil { + return nil, err + } + + config, err := s3response.ParseWebsiteConfigOutput(data) + if err != nil { + return nil, err + } + + return &websiteRequestInfo{ + bucket: bucket, + config: config, + key: key, + }, nil +} + +func validateWebsiteNames(bucket, key string) error { + if !utils.IsValidBucketName(bucket) { + return s3err.GetBucketErr(s3err.ErrInvalidBucketName, bucket) + } + if key != "" && !utils.IsObjectNameValid(key) { + return s3err.GetAPIError(s3err.ErrBadRequest) + } + + return nil +} + +// resolveBucket extracts the bucket name from the request host header. +// +// It strips the port when present before applying website endpoint routing. // // When domain is set: // - If host equals the domain exactly, the bucket IS the domain (apex). @@ -137,36 +257,146 @@ func newHandler(be backend.Backend, domain string) fiber.Handler { // // When domain is empty (catch-all mode): // - The full hostname is used as the bucket name. -func resolveBucket(host, domain, domainSuffix string) string { - if domain == "" { - // Catch-all: the full hostname is the bucket name - return host +func (c *websiteController) resolveBucket(ctx *fiber.Ctx) (string, error) { + host := ctx.Hostname() + if host == "" { + return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest) } - if strings.EqualFold(host, domain) { - return domain + // Strip port from host if present. Be careful with IPv6: only strip if the + // last colon is not inside brackets. + if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") { + host = host[:idx] } - lower := strings.ToLower(host) - if strings.HasSuffix(lower, strings.ToLower(domainSuffix)) { - sub := host[:len(host)-len(domainSuffix)] - if sub != "" && !strings.Contains(sub, ".") { - return sub + if c.domain == "" { + return host, nil + } + + if strings.EqualFold(host, c.domain) { + return c.domain, nil + } + + lowerHost := strings.ToLower(host) + lowerDomainSuffix := strings.ToLower(c.domainSuffix) + if strings.HasSuffix(lowerHost, lowerDomainSuffix) { + bucket := host[:len(host)-len(c.domainSuffix)] + if bucket != "" && !strings.Contains(bucket, ".") { + return bucket, nil } } - return "" + return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest) +} + +type websiteResult struct { + Key string + StatusCode int + Object websiteObject + Err error +} + +type websiteObject struct { + Body io.ReadCloser + Headers map[string]*string + Metadata map[string]string +} + +func resolveIndexKey(key string, config *s3response.WebsiteConfiguration) string { + if config.IndexDocument != nil && config.IndexDocument.Suffix != "" { + if key == "" || strings.HasSuffix(key, "/") { + return key + config.IndexDocument.Suffix + } + } + + return key +} + +func (c *websiteController) getObject(ctx *fiber.Ctx, bucket, key string) websiteResult { + if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil { + return websiteResult{ + Key: key, + StatusCode: statusCodeFromError(err), + Err: err, + } + } + + result, err := c.be.GetObject(ctx.Context(), &s3.GetObjectInput{ + Bucket: &bucket, + Key: &key, + }) + if err != nil { + return websiteResult{ + Key: key, + StatusCode: statusCodeFromError(err), + Err: err, + } + } + + return websiteResult{ + Key: key, + StatusCode: http.StatusOK, + Object: websiteObject{ + Body: result.Body, + Headers: getObjectHeaders(result), + Metadata: result.Metadata, + }, + } +} + +func (c *websiteController) headObject(ctx *fiber.Ctx, bucket, key string) websiteResult { + if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.ListBucketAction, auth.PermissionRead, bucket, key); err != nil { + return websiteResult{ + Key: key, + StatusCode: statusCodeFromError(err), + Err: err, + } + } + + result, err := c.be.HeadObject(ctx.Context(), &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &key, + }) + if err != nil { + return websiteResult{ + Key: key, + StatusCode: statusCodeFromError(err), + Err: err, + } + } + + return websiteResult{ + Key: key, + StatusCode: http.StatusOK, + Object: websiteObject{ + Headers: headObjectHeaders(result), + Metadata: result.Metadata, + }, + } +} + +func statusCodeFromError(err error) int { + var serr s3err.S3Error + if errors.As(err, &serr) { + return serr.StatusCode() + } + + return http.StatusInternalServerError } // handleRedirectAll sends a 301 redirect for RedirectAllRequestsTo configuration. func handleRedirectAll(ctx *fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error { protocol := redirect.Protocol if protocol == "" { - protocol = "https" + protocol = "http" } location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key) + if query := string(ctx.Request().URI().QueryString()); query != "" { + location += "?" + query + } ctx.Set("Location", location) + _, _ = utils.EnsureRequestIDs(ctx) return ctx.SendStatus(http.StatusMovedPermanently) } @@ -189,7 +419,7 @@ func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3r key = redirect.ReplaceKeyPrefixWith + strings.TrimPrefix(originalKey, condition.KeyPrefixEquals) } - httpCode := http.StatusFound // 302 default + httpCode := http.StatusMovedPermanently if redirect.HttpRedirectCode != "" { if code, err := strconv.Atoi(redirect.HttpRedirectCode); err == nil { httpCode = code @@ -197,118 +427,112 @@ func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3r } location := fmt.Sprintf("%s://%s/%s", protocol, host, key) + if query := string(ctx.Request().URI().QueryString()); query != "" { + location += "?" + query + } ctx.Set("Location", location) return ctx.SendStatus(httpCode) } -// serveObject writes the S3 object content to the response. -func serveObject(ctx *fiber.Ctx, result *s3.GetObjectOutput, key string) error { - contentType := guessContentType(result, key) - ctx.Set("Content-Type", contentType) +func getObjectHeaders(result *s3.GetObjectOutput) map[string]*string { + return map[string]*string{ + "ETag": result.ETag, + "accept-ranges": result.AcceptRanges, + "Cache-Control": result.CacheControl, + "Content-Disposition": result.ContentDisposition, + "Content-Encoding": result.ContentEncoding, + "Content-Language": result.ContentLanguage, + "Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength), + "Content-Range": result.ContentRange, + "Content-Type": result.ContentType, + "Expires": result.ExpiresString, + "Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat), + "x-amz-restore": result.Restore, + "x-amz-version-id": result.VersionId, + } +} - if result.ETag != nil { - ctx.Set("ETag", *result.ETag) +func headObjectHeaders(result *s3.HeadObjectOutput) map[string]*string { + return map[string]*string{ + "ETag": result.ETag, + "accept-ranges": result.AcceptRanges, + "Cache-Control": result.CacheControl, + "Content-Disposition": result.ContentDisposition, + "Content-Encoding": result.ContentEncoding, + "Content-Language": result.ContentLanguage, + "Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength), + "Content-Range": result.ContentRange, + "Content-Type": result.ContentType, + "Expires": result.ExpiresString, + "Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat), + "x-amz-restore": result.Restore, + "x-amz-version-id": result.VersionId, } - if result.CacheControl != nil { - ctx.Set("Cache-Control", *result.CacheControl) - } - if result.ContentEncoding != nil { - ctx.Set("Content-Encoding", *result.ContentEncoding) - } - if result.ContentLanguage != nil { - ctx.Set("Content-Language", *result.ContentLanguage) - } - if result.ContentLength != nil { - ctx.Set("Content-Length", strconv.FormatInt(*result.ContentLength, 10)) - } - if result.LastModified != nil { - ctx.Set("Last-Modified", result.LastModified.UTC().Format(http.TimeFormat)) +} + +func serveWebsiteResult(ctx *fiber.Ctx, bucket string, config *s3response.WebsiteConfiguration, result websiteResult, readObject websiteObjectReader) error { + if result.Err == nil { + return serveObject(ctx, result.Object, http.StatusOK) } - _, err := io.Copy(ctx.Response().BodyWriter(), result.Body) + if config.ErrorDocument != nil && config.ErrorDocument.Key != "" { + return serveErrorDocument(ctx, readObject, bucket, config.ErrorDocument.Key, result.StatusCode) + } + + return sendError(ctx, result.Err) +} + +func serveObject(ctx *fiber.Ctx, object websiteObject, statusCode int) error { + ctx.Status(statusCode) + setWebsiteObjectHeaders(ctx, object) + + if object.Body == nil { + return nil + } + defer object.Body.Close() + + _, err := io.Copy(ctx.Response().BodyWriter(), object.Body) if err != nil { - return sendError(ctx, http.StatusInternalServerError, "Internal Server Error", - "Failed to read object") + return sendError(ctx, err) } return nil } +func setWebsiteObjectHeaders(ctx *fiber.Ctx, object websiteObject) { + utils.SetMetaHeaders(ctx, object.Metadata) + for key, value := range object.Headers { + if value != nil && *value != "" { + ctx.Set(key, *value) + } + } +} + // serveErrorDocument fetches and serves the configured error document. -func serveErrorDocument(ctx *fiber.Ctx, be backend.Backend, bucket, errorDocKey string, statusCode int) error { - emptyRange := "" - result, err := be.GetObject(ctx.Context(), &s3.GetObjectInput{ - Bucket: &bucket, - Key: &errorDocKey, - Range: &emptyRange, - }) - if err != nil { - return sendError(ctx, statusCode, "Not Found", "The specified key does not exist") - } - if result.Body == nil { - return sendError(ctx, statusCode, "Not Found", "The specified key does not exist") - } - defer result.Body.Close() - - contentType := guessContentType(result, errorDocKey) - ctx.Set("Content-Type", contentType) - - ctx.Status(statusCode) - _, writeErr := io.Copy(ctx.Response().BodyWriter(), result.Body) - if writeErr != nil { - return sendError(ctx, statusCode, "Not Found", "The specified key does not exist") +func serveErrorDocument(ctx *fiber.Ctx, readObject websiteObjectReader, bucket, errorDocKey string, statusCode int) error { + result := readObject(ctx, bucket, errorDocKey) + if result.Err != nil { + return sendError(ctx, result.Err) } - return nil -} - -// guessContentType returns the content type from the GetObject result, or -// infers it from the key extension, defaulting to text/html. -func guessContentType(result *s3.GetObjectOutput, key string) string { - if result.ContentType != nil && *result.ContentType != "" { - return *result.ContentType - } - - // Simple extension-based inference for common web types - switch { - case strings.HasSuffix(key, ".html"), strings.HasSuffix(key, ".htm"): - return "text/html; charset=utf-8" - case strings.HasSuffix(key, ".css"): - return "text/css; charset=utf-8" - case strings.HasSuffix(key, ".js"): - return "application/javascript" - case strings.HasSuffix(key, ".json"): - return "application/json" - case strings.HasSuffix(key, ".xml"): - return "application/xml" - case strings.HasSuffix(key, ".svg"): - return "image/svg+xml" - case strings.HasSuffix(key, ".png"): - return "image/png" - case strings.HasSuffix(key, ".jpg"), strings.HasSuffix(key, ".jpeg"): - return "image/jpeg" - case strings.HasSuffix(key, ".gif"): - return "image/gif" - case strings.HasSuffix(key, ".ico"): - return "image/x-icon" - case strings.HasSuffix(key, ".txt"): - return "text/plain; charset=utf-8" - default: - return "text/html; charset=utf-8" - } + return serveObject(ctx, result.Object, statusCode) } // sendError sends a simple HTML error page. -func sendError(ctx *fiber.Ctx, statusCode int, title, message string) error { - ctx.Set("Content-Type", "text/html; charset=utf-8") - ctx.Status(statusCode) - body := fmt.Sprintf(` - -%d %s - -

    %d %s

    -

    %s

    - -`, statusCode, title, statusCode, title, message) - return ctx.SendString(body) +func sendError(ctx *fiber.Ctx, err error) error { + requestId, hostId := utils.EnsureRequestIDs(ctx) + serr, ok := err.(s3err.S3Error) + if !ok { + debuglogger.InternalError(err) + serr = s3err.GetAPIError(s3err.ErrInternalError) + } + + ctx.Response().Header.Set("x-amz-error-code", serr.BaseError().Code) + ctx.Response().Header.Set("x-amz-error-message", serr.BaseError().Description) + if methodErr, ok := serr.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 { + ctx.Response().Header.Set("Allow", methodErr.AllowedMethodsString()) + } + + ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) + return ctx.Status(serr.StatusCode()).Send(serr.HTMLBody(requestId, hostId)) } diff --git a/website/handler_test.go b/website/handler_test.go new file mode 100644 index 00000000..486de5cc --- /dev/null +++ b/website/handler_test.go @@ -0,0 +1,972 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package website + +import ( + "context" + "encoding/json" + "encoding/xml" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/gofiber/fiber/v2" + "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/s3err" + "github.com/versity/versitygw/s3response" +) + +type websiteTestBackend struct { + backend.BackendUnsupported + + websiteConfig []byte + corsConfig []byte + corsErr error + objects map[string]string + objectErrors map[string]error + public bool + calls []string +} + +func (b *websiteTestBackend) record(call string) { + b.calls = append(b.calls, call) +} + +func (b *websiteTestBackend) GetBucketWebsite(_ context.Context, _ string) ([]byte, error) { + b.record("GetBucketWebsite") + return b.websiteConfig, nil +} + +func (b *websiteTestBackend) GetBucketCors(_ context.Context, _ string) ([]byte, error) { + b.record("GetBucketCors") + if b.corsErr != nil { + return nil, b.corsErr + } + if b.corsConfig == nil { + return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration) + } + return b.corsConfig, nil +} + +func (b *websiteTestBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) { + b.record("GetBucketPolicy") + return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy) +} + +func (b *websiteTestBackend) GetBucketAcl(_ context.Context, _ *s3.GetBucketAclInput) ([]byte, error) { + b.record("GetBucketAcl") + acl := auth.ACL{Owner: "owner"} + if b.public { + acl.Grantees = []auth.Grantee{ + { + Permission: auth.PermissionRead, + Access: "all-users", + Type: types.TypeGroup, + }, + } + } + + data, err := json.Marshal(acl) + if err != nil { + return nil, err + } + return data, nil +} + +func (b *websiteTestBackend) HeadObject(_ context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) { + b.record("HeadObject") + if input == nil || input.Key == nil { + return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) + } + if err, ok := b.objectErrors[*input.Key]; ok { + return nil, err + } + body, ok := b.objects[*input.Key] + if !ok { + return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) + } + + length := int64(len(body)) + contentType := "text/html" + return &s3.HeadObjectOutput{ + ContentLength: &length, + ContentType: &contentType, + }, nil +} + +func (b *websiteTestBackend) GetObject(_ context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + b.record("GetObject") + if input == nil || input.Key == nil { + return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) + } + if err, ok := b.objectErrors[*input.Key]; ok { + return nil, err + } + body, ok := b.objects[*input.Key] + if !ok { + return nil, s3err.GetAPIError(s3err.ErrNoSuchKey) + } + + length := int64(len(body)) + contentType := "text/html" + return &s3.GetObjectOutput{ + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: &length, + ContentType: &contentType, + }, nil +} + +func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) { + tests := []struct { + name string + rules []s3response.RoutingRule + wantStatus int + wantLocation string + }{ + { + name: "key prefix rule before 404 rule wins", + rules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + HttpRedirectCode: "301", + }, + }, + { + Condition: &s3response.RoutingRuleCondition{ + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: s3response.Redirect{ + ReplaceKeyWith: "error.html", + HttpRedirectCode: "302", + }, + }, + }, + wantStatus: http.StatusMovedPermanently, + wantLocation: "http://site.test/new/missing.html", + }, + { + name: "key prefix rule wins pre-fetch even when 404 rule comes first", + rules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: s3response.Redirect{ + ReplaceKeyWith: "error.html", + HttpRedirectCode: "302", + }, + }, + { + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + HttpRedirectCode: "301", + }, + }, + }, + wantStatus: http.StatusMovedPermanently, + wantLocation: "http://site.test/new/missing.html", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: tt.rules, + }, nil, true) + + resp := websiteRequest(t, be, "/old/missing.html") + defer resp.Body.Close() + + if resp.StatusCode != tt.wantStatus { + t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus) + } + if got := resp.Header.Get("Location"); got != tt.wantLocation { + t.Fatalf("Location = %q, want %q", got, tt.wantLocation) + } + if containsCall(be.calls, "GetObject") { + t.Fatal("GetObject was called for a redirect response") + } + }) + } +} + +func TestWebsiteHandlerRoutingRuleBothConditions(t *testing.T) { + config := s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + HttpRedirectCode: "302", + }, + }, + }, + } + + t.Run("missing object with matching prefix redirects", func(t *testing.T) { + be := newWebsiteTestBackend(t, config, nil, true) + resp := websiteRequest(t, be, "/old/missing.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got := resp.Header.Get("Location"); got != "http://site.test/new/missing.html" { + t.Fatalf("Location = %q", got) + } + }) + + t.Run("existing object with matching prefix does not redirect", func(t *testing.T) { + be := newWebsiteTestBackend(t, config, map[string]string{ + "old/existing.html": "served", + }, true) + resp := websiteRequest(t, be, "/old/existing.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := readBody(t, resp); got != "served" { + t.Fatalf("body = %q, want %q", got, "served") + } + if got := resp.Header.Get("Location"); got != "" { + t.Fatalf("unexpected Location header %q", got) + } + }) + + t.Run("missing object with wrong prefix does not redirect", func(t *testing.T) { + be := newWebsiteTestBackend(t, config, nil, true) + resp := websiteRequest(t, be, "/other/missing.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound) + } + if got := resp.Header.Get("Location"); got != "" { + t.Fatalf("unexpected Location header %q", got) + } + }) +} + +func TestWebsiteHandlerRedirectConstruction(t *testing.T) { + tests := []struct { + name string + rule s3response.RoutingRule + path string + wantLocation string + }{ + { + name: "ReplaceKeyWith replaces full key", + rule: s3response.RoutingRule{ + Condition: &s3response.RoutingRuleCondition{ + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: s3response.Redirect{ + ReplaceKeyWith: "error.html", + }, + }, + path: "/a/b/c.html", + wantLocation: "http://site.test/error.html", + }, + { + name: "ReplaceKeyPrefixWith replaces matching prefix", + rule: s3response.RoutingRule{ + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + }, + }, + path: "/old/a/b.html", + wantLocation: "http://site.test/new/a/b.html", + }, + { + name: "HostName Protocol and query string are preserved", + rule: s3response.RoutingRule{ + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: s3response.Redirect{ + HostName: "example.com", + Protocol: "https", + ReplaceKeyPrefixWith: "new/", + }, + }, + path: "/old/page.html?x=1&y=2", + wantLocation: "https://example.com/new/page.html?x=1&y=2", + }, + { + name: "query string is preserved with current endpoint host", + rule: s3response.RoutingRule{ + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + }, + }, + path: "/old/page.html?x=1&y=2", + wantLocation: "http://site.test/new/page.html?x=1&y=2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: []s3response.RoutingRule{tt.rule}, + }, nil, true) + + resp := websiteRequest(t, be, tt.path) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMovedPermanently { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently) + } + if got := resp.Header.Get("Location"); got != tt.wantLocation { + t.Fatalf("Location = %q, want %q", got, tt.wantLocation) + } + }) + } +} + +func TestWebsiteHandlerPostErrorRoutingUsesOriginalKeyBeforeIndexExpansion(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "blog/", + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: s3response.Redirect{ + ReplaceKeyPrefixWith: "archive/", + HttpRedirectCode: "302", + }, + }, + }, + }, nil, true) + + resp := websiteRequest(t, be, "/blog/") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got := resp.Header.Get("Location"); got != "http://site.test/archive/" { + t.Fatalf("Location = %q, want %q", got, "http://site.test/archive/") + } + if countCalls(be.calls, "GetObject") != 1 { + t.Fatalf("GetObject calls = %d, want 1; calls: %v", countCalls(be.calls, "GetObject"), be.calls) + } +} + +func TestWebsiteHandlerObjectStore5xxBypassesRoutingAndErrorDocument(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + ErrorDocument: &s3response.ErrorDocument{Key: "error.html"}, + RoutingRules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + HttpErrorCodeReturnedEquals: "500", + }, + Redirect: s3response.Redirect{ + ReplaceKeyWith: "elsewhere.html", + HttpRedirectCode: "302", + }, + }, + }, + }, map[string]string{ + "error.html": "custom error document", + }, true) + be.objectErrors = map[string]error{ + "boom.html": s3err.GetAPIError(s3err.ErrInternalError), + } + + resp := websiteRequest(t, be, "/boom.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusInternalServerError) + } + if got := resp.Header.Get("Location"); got != "" { + t.Fatalf("unexpected Location header %q", got) + } + if got := resp.Header.Get("x-amz-error-code"); got != "InternalError" { + t.Fatalf("x-amz-error-code = %q, want %q", got, "InternalError") + } + if got := countCalls(be.calls, "GetObject"); got != 1 { + t.Fatalf("GetObject calls = %d, want 1; calls: %v", got, be.calls) + } +} + +func TestWebsiteHandlerPublicAccessDeniedPreventsObjectReadAndCanRoute(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + HttpErrorCodeReturnedEquals: "403", + }, + Redirect: s3response.Redirect{ + ReplaceKeyWith: "denied.html", + HttpRedirectCode: "302", + }, + }, + }, + }, map[string]string{ + "private.html": "secret", + }, false) + + resp := websiteRequest(t, be, "/private.html") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got := resp.Header.Get("Location"); got != "http://site.test/denied.html" { + t.Fatalf("Location = %q", got) + } + if containsCall(be.calls, "HeadObject") { + t.Fatal("HeadObject was called after public access was denied") + } + if containsCall(be.calls, "GetObject") { + t.Fatal("GetObject was called after public access was denied") + } +} + +func TestWebsiteHandlerVerifiesPublicAccessBeforeGetObject(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "index.html": "home", + }, true) + + resp := websiteRequest(t, be, "/") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := readBody(t, resp); got != "home" { + t.Fatalf("body = %q, want %q", got, "home") + } + + verifyIdx := firstCallIndex(be.calls, "GetBucketAcl") + getObjectIdx := firstCallIndex(be.calls, "GetObject") + if verifyIdx == -1 { + t.Fatal("expected public access verification to read bucket ACL") + } + if getObjectIdx == -1 { + t.Fatal("expected GetObject call") + } + if verifyIdx > getObjectIdx { + t.Fatalf("GetObject happened before public access verification: %v", be.calls) + } +} + +func TestWebsiteHandlerHeadUsesHeadObjectAndReturnsHeadersOnly(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "index.html": "home", + }, true) + + resp := websiteRequestWithMethod(t, be, http.MethodHead, "/") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := resp.Header.Get("Content-Length"); got != "4" { + t.Fatalf("Content-Length = %q, want %q", got, "4") + } + if got := resp.Header.Get("Content-Type"); got != "text/html" { + t.Fatalf("Content-Type = %q, want %q", got, "text/html") + } + if got := readBody(t, resp); got != "" { + t.Fatalf("body = %q, want empty body", got) + } + if containsCall(be.calls, "GetObject") { + t.Fatalf("GetObject was called for HEAD request: %v", be.calls) + } + if !containsCall(be.calls, "HeadObject") { + t.Fatalf("HeadObject was not called for HEAD request: %v", be.calls) + } +} + +func TestWebsiteHandlerGetValidatesBucketName(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithHostAndHeaders(t, be, http.MethodGet, "bad_bucket", "/", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + if got := resp.Header.Get("x-amz-error-code"); got != "InvalidBucketName" { + t.Fatalf("x-amz-error-code = %q", got) + } + if len(be.calls) != 0 { + t.Fatalf("invalid bucket should not call backend, got calls: %v", be.calls) + } +} + +func TestWebsiteHandlerHeadValidatesObjectName(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithHeaders(t, be, http.MethodHead, "/../../private.html", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + if got := resp.Header.Get("x-amz-error-code"); got != "400" { + t.Fatalf("x-amz-error-code = %q", got) + } + if len(be.calls) != 0 { + t.Fatalf("invalid object should not call backend, got calls: %v", be.calls) + } +} + +func TestWebsiteHandlerGetAppliesBucketCORS(t *testing.T) { + corsConfig, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"https://client.example"}, + AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodHead}, + ExposeHeaders: []auth.CORSHeader{"Content-Length"}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal cors config: %v", err) + } + + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "index.html": "home", + }, true) + be.corsConfig = corsConfig + + resp := websiteRequestWithHeaders(t, be, http.MethodGet, "/", map[string]string{ + "Origin": "https://client.example", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" { + t.Fatalf("Access-Control-Allow-Origin = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, HEAD" { + t.Fatalf("Access-Control-Allow-Methods = %q", got) + } + if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag, x-amz-storage-class" { + t.Fatalf("Access-Control-Expose-Headers = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("Access-Control-Allow-Credentials = %q", got) + } + if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" { + t.Fatalf("Vary = %q", got) + } + if got := readBody(t, resp); got != "home" { + t.Fatalf("body = %q, want %q", got, "home") + } + if !containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was not called: %v", be.calls) + } +} + +func TestWebsiteHandlerHeadAppliesBucketCORS(t *testing.T) { + corsConfig, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"https://client.example"}, + AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead}, + ExposeHeaders: []auth.CORSHeader{"Content-Length"}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal cors config: %v", err) + } + + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "index.html": "home", + }, true) + be.corsConfig = corsConfig + + resp := websiteRequestWithHeaders(t, be, http.MethodHead, "/", map[string]string{ + "Origin": "https://client.example", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" { + t.Fatalf("Access-Control-Allow-Origin = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "HEAD" { + t.Fatalf("Access-Control-Allow-Methods = %q", got) + } + if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag, x-amz-storage-class" { + t.Fatalf("Access-Control-Expose-Headers = %q", got) + } + if got := readBody(t, resp); got != "" { + t.Fatalf("body = %q, want empty body", got) + } + if !containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was not called: %v", be.calls) + } + if containsCall(be.calls, "GetObject") { + t.Fatalf("GetObject was called for HEAD request: %v", be.calls) + } +} + +func TestWebsiteHandlerOptionsAccessGranted(t *testing.T) { + maxAge := int32(42) + corsConfig, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"https://client.example"}, + AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodHead}, + AllowedHeaders: []auth.CORSHeader{"Content-Type", "X-Amz-Date"}, + ExposeHeaders: []auth.CORSHeader{"Content-Length"}, + MaxAgeSeconds: &maxAge, + }, + }, + }) + if err != nil { + t.Fatalf("marshal cors config: %v", err) + } + + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "index.html": "home", + }, true) + be.corsConfig = corsConfig + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + "Access-Control-Request-Headers": "content-type, X-Amz-Date", + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" { + t.Fatalf("Access-Control-Allow-Origin = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, HEAD" { + t.Fatalf("Access-Control-Allow-Methods = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "content-type, x-amz-date" { + t.Fatalf("Access-Control-Allow-Headers = %q", got) + } + if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag" { + t.Fatalf("Access-Control-Expose-Headers = %q", got) + } + if got := resp.Header.Get("Access-Control-Max-Age"); got != "42" { + t.Fatalf("Access-Control-Max-Age = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("Access-Control-Allow-Credentials = %q", got) + } + if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" { + t.Fatalf("Vary = %q", got) + } + if got := readBody(t, resp); got != "" { + t.Fatalf("body = %q, want empty body", got) + } + if !containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was not called: %v", be.calls) + } + for _, unexpected := range []string{"GetBucketWebsite", "GetObject", "HeadObject", "GetBucketAcl"} { + if containsCall(be.calls, unexpected) { + t.Fatalf("%s was called for OPTIONS request: %v", unexpected, be.calls) + } + } +} + +func TestWebsiteHandlerOptionsMissingOrigin(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{ + "Access-Control-Request-Method": http.MethodGet, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" { + t.Fatalf("x-amz-error-code = %q", got) + } + if containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was called despite missing origin: %v", be.calls) + } +} + +func TestWebsiteHandlerOptionsInvalidRequestMethod(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodOptions, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" { + t.Fatalf("x-amz-error-code = %q", got) + } + if containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was called despite invalid request method: %v", be.calls) + } +} + +func TestWebsiteHandlerOptionsUnsetBucketCORS(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + be.corsErr = s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration) + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) + } + if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" { + t.Fatalf("x-amz-error-code = %q", got) + } + body := readBody(t, resp) + for _, want := range []string{ + "
  • Method: OPTIONS
  • ", + "
  • ResourceType: BUCKET
  • ", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q: %s", want, body) + } + } +} + +func TestWebsiteHandlerOptionsAccessForbidden(t *testing.T) { + corsConfig, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"https://client.example"}, + AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal cors config: %v", err) + } + + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + be.corsConfig = corsConfig + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) + } + if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" { + t.Fatalf("x-amz-error-code = %q", got) + } + body := readBody(t, resp) + for _, want := range []string{ + "
  • Method: OPTIONS
  • ", + "
  • ResourceType: OBJECT
  • ", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q: %s", want, body) + } + } +} + +func TestWebsiteHandlerMethodNotAllowed(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithMethod(t, be, http.MethodPut, "/some-key") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed) + } + if got := resp.Header.Get("Allow"); got != "GET, HEAD, OPTIONS" { + t.Fatalf("Allow = %q, want %q", got, "GET, HEAD, OPTIONS") + } + if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", got) + } + if got := resp.Header.Get("Server"); got != "VERSITYGW" { + t.Fatalf("Server = %q, want %q", got, "VERSITYGW") + } + + body := readBody(t, resp) + for _, want := range []string{ + "
  • Code: MethodNotAllowed
  • ", + "
  • Method: PUT
  • ", + "
  • ResourceType: OBJECT
  • ", + } { + if !strings.Contains(body, want) { + t.Fatalf("method not allowed body missing %q: %s", want, body) + } + } + if containsCall(be.calls, "GetBucketWebsite") { + t.Fatalf("unmatched method should not load website config: %v", be.calls) + } +} + +func newWebsiteTestBackend(t *testing.T, config s3response.WebsiteConfiguration, objects map[string]string, public bool) *websiteTestBackend { + t.Helper() + + data, err := xml.Marshal(config) + if err != nil { + t.Fatalf("marshal website config: %v", err) + } + if objects == nil { + objects = map[string]string{} + } + + return &websiteTestBackend{ + websiteConfig: data, + objects: objects, + public: public, + } +} + +func websiteRequest(t *testing.T, be backend.Backend, path string) *http.Response { + t.Helper() + + return websiteRequestWithMethod(t, be, http.MethodGet, path) +} + +func websiteRequestWithMethod(t *testing.T, be backend.Backend, method, path string) *http.Response { + t.Helper() + + return websiteRequestWithHeaders(t, be, method, path, nil) +} + +func websiteRequestWithHeaders(t *testing.T, be backend.Backend, method, path string, headers map[string]string) *http.Response { + t.Helper() + + return websiteRequestWithHostAndHeaders(t, be, method, "site.test", path, headers) +} + +func websiteRequestWithHostAndHeaders(t *testing.T, be backend.Backend, method, host, path string, headers map[string]string) *http.Response { + t.Helper() + + app := fiber.New(fiber.Config{ + ServerHeader: "VERSITYGW", + }) + registerWebsiteRoutes(app, be, "") + + req := httptest.NewRequest(method, path, nil) + req.Host = host + req.Header.Set("Host", host) + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := app.Test(req, -1) + if err != nil { + t.Fatalf("website request failed: %v", err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + return string(body) +} + +func containsCall(calls []string, want string) bool { + return firstCallIndex(calls, want) != -1 +} + +func countCalls(calls []string, want string) int { + var count int + for _, call := range calls { + if call == want { + count++ + } + } + return count +} + +func firstCallIndex(calls []string, want string) int { + for i, call := range calls { + if call == want { + return i + } + } + return -1 +} diff --git a/website/server.go b/website/server.go index 398d1d72..c9c18d88 100644 --- a/website/server.go +++ b/website/server.go @@ -17,11 +17,14 @@ package website import ( "fmt" "net" + "os" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/s3api/middlewares" "github.com/versity/versitygw/s3api/utils" ) @@ -31,6 +34,7 @@ type Server struct { CertStorage *utils.CertStorage domain string quiet bool + socketPerm os.FileMode } // Option sets various options for NewServer(). @@ -46,6 +50,13 @@ func WithTLS(cs *utils.CertStorage) Option { return func(s *Server) { s.CertStorage = cs } } +// WithSocketPerm sets the file-mode permissions applied to file-backed UNIX +// domain sockets after binding. It has no effect on TCP/IP or abstract +// namespace sockets. +func WithSocketPerm(perm os.FileMode) Option { + return func(s *Server) { s.socketPerm = perm } +} + // NewServer creates a new static website hosting server. // The domain parameter is the base domain for virtual-host routing: // - Host "blog." resolves to bucket "blog" @@ -83,8 +94,12 @@ func NewServer(be backend.Backend, domain string, opts ...Option) *Server { })) } - // All requests go through the website handler - app.Use(newHandler(be, domain)) + // initialize the debug logger in debug mode + if debuglogger.IsDebugEnabled() { + app.Use(middlewares.DebugLogger()) + } + + registerWebsiteRoutes(app, be, domain) return server } @@ -103,9 +118,9 @@ func (s *Server) ServeMultiPort(ports []string) error { var err error if s.CertStorage != nil { - ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate) + ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: s.socketPerm}) } else { - ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec) + ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec, utils.ListenerOptions{SocketPerm: s.socketPerm}) } if err != nil {