diff --git a/config/gateway.go b/config/gateway.go index 3495caede94..0ce7638f4f5 100644 --- a/config/gateway.go +++ b/config/gateway.go @@ -13,6 +13,7 @@ const ( // Gateway limit defaults from boxo DefaultRetrievalTimeout = gateway.DefaultRetrievalTimeout + DefaultMaxRequestDuration = gateway.DefaultMaxRequestDuration DefaultMaxConcurrentRequests = gateway.DefaultMaxConcurrentRequests DefaultMaxRangeRequestFileSize = 0 // 0 means no limit ) @@ -96,6 +97,14 @@ type Gateway struct { // A value of 0 disables this timeout. RetrievalTimeout *OptionalDuration `json:",omitempty"` + // MaxRequestDuration is an absolute deadline for the entire request. + // Unlike RetrievalTimeout (which resets on each data write and catches + // stalled transfers), this is a hard limit on the total time a request + // can take. Returns 504 Gateway Timeout when exceeded. + // This protects the gateway from edge cases and slow client attacks. + // A value of 0 uses the default (1 hour). + MaxRequestDuration *OptionalDuration `json:",omitempty"` + // MaxConcurrentRequests limits concurrent HTTP requests handled by the gateway. // Requests beyond this limit receive 429 Too Many Requests with Retry-After header. // A value of 0 disables the limit. diff --git a/core/corehttp/gateway.go b/core/corehttp/gateway.go index 393a668bfe1..6bac662aea8 100644 --- a/core/corehttp/gateway.go +++ b/core/corehttp/gateway.go @@ -112,6 +112,7 @@ func Libp2pGatewayOption() ServeOption { Menu: nil, // Apply timeout and concurrency limits from user config RetrievalTimeout: cfg.Gateway.RetrievalTimeout.WithDefault(config.DefaultRetrievalTimeout), + MaxRequestDuration: cfg.Gateway.MaxRequestDuration.WithDefault(config.DefaultMaxRequestDuration), MaxConcurrentRequests: int(cfg.Gateway.MaxConcurrentRequests.WithDefault(int64(config.DefaultMaxConcurrentRequests))), MaxRangeRequestFileSize: int64(cfg.Gateway.MaxRangeRequestFileSize.WithDefault(uint64(config.DefaultMaxRangeRequestFileSize))), DiagnosticServiceURL: "", // Not used since DisableHTMLErrors=true @@ -272,6 +273,7 @@ func getGatewayConfig(n *core.IpfsNode) (gateway.Config, map[string][]string, er NoDNSLink: cfg.Gateway.NoDNSLink, PublicGateways: map[string]*gateway.PublicGateway{}, RetrievalTimeout: cfg.Gateway.RetrievalTimeout.WithDefault(config.DefaultRetrievalTimeout), + MaxRequestDuration: cfg.Gateway.MaxRequestDuration.WithDefault(config.DefaultMaxRequestDuration), MaxConcurrentRequests: int(cfg.Gateway.MaxConcurrentRequests.WithDefault(int64(config.DefaultMaxConcurrentRequests))), MaxRangeRequestFileSize: int64(cfg.Gateway.MaxRangeRequestFileSize.WithDefault(uint64(config.DefaultMaxRangeRequestFileSize))), DiagnosticServiceURL: cfg.Gateway.DiagnosticServiceURL.WithDefault(config.DefaultDiagnosticServiceURL), diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index dcdf9a27d11..f45fa30a1d7 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -17,6 +17,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [Improved `ipfs dag stat` output](#improved-ipfs-dag-stat-output) - [Skip bad keys when listing](#skip_bad_keys_when_listing) - [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together) + - [⏱️ Configurable gateway request duration limit](#️-configurable-gateway-request-duration-limit) - [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root) - [📦️ Dependency updates](#-dependency-updates) - [📝 Changelog](#-changelog) @@ -97,6 +98,12 @@ Change the `ipfs key list` behavior to log an error and continue listing keys wh Previously, provide operations could start before the Accelerated DHT Client discovered enough peers, causing sweep mode to lose its efficiency benefits. Now, providing waits for the initial network crawl (about 10 minutes). Your content will be properly distributed across DHT regions after initial DHT map is created. Check `ipfs provide stat` to see when providing begins. +#### ⏱️ Configurable gateway request duration limit + +[`Gateway.MaxRequestDuration`](https://github.com/ipfs/kubo/blob/master/docs/config.md#gatewaymaxrequestduration) sets an absolute deadline for gateway requests. Unlike `RetrievalTimeout` (which resets on each data write and catches stalled transfers), this is a hard limit on the total time a request can take. + +The default 1 hour limit (previously hardcoded) can now be adjusted to fit your deployment needs. This is a fallback that prevents requests from hanging indefinitely when subsystem timeouts are misconfigured or fail to trigger. Returns 504 Gateway Timeout when exceeded. + #### 🔧 Recovery from corrupted MFS root If your daemon fails to start because the MFS root is not a directory (due to misconfiguration, operational error, or disk corruption), you can now recover without deleting and recreating your repository in a new `IPFS_PATH`. diff --git a/docs/config.md b/docs/config.md index 986798296b6..99b92838cc1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -67,6 +67,7 @@ config file at runtime. - [`Gateway.DisableHTMLErrors`](#gatewaydisablehtmlerrors) - [`Gateway.ExposeRoutingAPI`](#gatewayexposeroutingapi) - [`Gateway.RetrievalTimeout`](#gatewayretrievaltimeout) + - [`Gateway.MaxRequestDuration`](#gatewaymaxrequestduration) - [`Gateway.MaxRangeRequestFileSize`](#gatewaymaxrangerequestfilesize) - [`Gateway.MaxConcurrentRequests`](#gatewaymaxconcurrentrequests) - [`Gateway.HTTPHeaders`](#gatewayhttpheaders) @@ -1178,6 +1179,16 @@ Default: `30s` Type: `optionalDuration` +### `Gateway.MaxRequestDuration` + +An absolute deadline for the entire gateway request. Unlike [`RetrievalTimeout`](#gatewayretrievaltimeout) (which resets on each data write and catches stalled transfers), this is a hard limit on the total time a request can take. + +Returns 504 Gateway Timeout when exceeded. This protects the gateway from edge cases and slow client attacks. + +Default: `1h` + +Type: `optionalDuration` + ### `Gateway.MaxRangeRequestFileSize` Maximum file size for HTTP range requests on deserialized responses. Range requests for files larger than this limit return 501 Not Implemented. diff --git a/docs/examples/kubo-as-a-library/go.mod b/docs/examples/kubo-as-a-library/go.mod index f59d136d609..dfe565a085b 100644 --- a/docs/examples/kubo-as-a-library/go.mod +++ b/docs/examples/kubo-as-a-library/go.mod @@ -7,7 +7,7 @@ go 1.25 replace github.com/ipfs/kubo => ./../../.. require ( - github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c + github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2 github.com/ipfs/kubo v0.0.0-00010101000000-000000000000 github.com/libp2p/go-libp2p v0.46.0 github.com/multiformats/go-multiaddr v0.16.1 diff --git a/docs/examples/kubo-as-a-library/go.sum b/docs/examples/kubo-as-a-library/go.sum index 790250ba17a..82ae3efd058 100644 --- a/docs/examples/kubo-as-a-library/go.sum +++ b/docs/examples/kubo-as-a-library/go.sum @@ -265,8 +265,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= -github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c h1:mczpALnNzNhmggehO5Ehr9+Q8+NiJyKJfT4EPwi01d0= -github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c/go.mod h1:Abmp1if6bMQG87/0SQPIB9fkxJnZMLCt2nQw3yUZHH0= +github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2 h1:pRQYSSGnGQa921d8v0uhXg2BGzoSf9ndTWTlR7ImVoo= +github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2/go.mod h1:Abmp1if6bMQG87/0SQPIB9fkxJnZMLCt2nQw3yUZHH0= github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA= github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk= diff --git a/docs/gateway.md b/docs/gateway.md index d51eab4cc09..31b753e5126 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -109,7 +109,9 @@ When deploying Kubo's gateway in production, be aware of these important conside > [!IMPORTANT] > **Timeouts:** Configure [`Gateway.RetrievalTimeout`](config.md#gatewayretrievaltimeout) -> based on your expected content retrieval times. +> to terminate stalled transfers (resets on each data write, catches unresponsive operations), +> and [`Gateway.MaxRequestDuration`](config.md#gatewaymaxrequestduration) as a fallback +> deadline (default: 1 hour, catches cases when other timeouts are misconfigured or fail to fire). > [!IMPORTANT] > **Rate Limiting:** Use [`Gateway.MaxConcurrentRequests`](config.md#gatewaymaxconcurrentrequests) diff --git a/go.mod b/go.mod index cba15d0a5d3..83999d59d41 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/hashicorp/go-version v1.7.0 github.com/ipfs-shipyard/nopfs v0.0.14 github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 - github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c + github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2 github.com/ipfs/go-block-format v0.2.3 github.com/ipfs/go-cid v0.6.0 github.com/ipfs/go-cidutil v0.1.0 diff --git a/go.sum b/go.sum index b5833a54ad5..1f7f18d6a95 100644 --- a/go.sum +++ b/go.sum @@ -336,8 +336,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= -github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c h1:mczpALnNzNhmggehO5Ehr9+Q8+NiJyKJfT4EPwi01d0= -github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c/go.mod h1:Abmp1if6bMQG87/0SQPIB9fkxJnZMLCt2nQw3yUZHH0= +github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2 h1:pRQYSSGnGQa921d8v0uhXg2BGzoSf9ndTWTlR7ImVoo= +github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2/go.mod h1:Abmp1if6bMQG87/0SQPIB9fkxJnZMLCt2nQw3yUZHH0= github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA= github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk= diff --git a/test/cli/gateway_limits_test.go b/test/cli/gateway_limits_test.go index 990eabb1a7f..3e3b8540a2c 100644 --- a/test/cli/gateway_limits_test.go +++ b/test/cli/gateway_limits_test.go @@ -58,6 +58,47 @@ func TestGatewayLimits(t *testing.T) { assert.Contains(t, resp.Body, "Unable to retrieve content within timeout period") }) + t.Run("MaxRequestDuration", func(t *testing.T) { + t.Parallel() + + // Create a node with a short max request duration + node := harness.NewT(t).NewNode().Init() + node.UpdateConfig(func(cfg *config.Config) { + // Set a short absolute deadline (500ms) for the entire request + cfg.Gateway.MaxRequestDuration = config.NewOptionalDuration(500 * time.Millisecond) + // Set retrieval timeout much longer so MaxRequestDuration fires first + cfg.Gateway.RetrievalTimeout = config.NewOptionalDuration(30 * time.Second) + }) + node.StartDaemon() + defer node.StopDaemon() + + // Add content that can be retrieved quickly + cid := node.IPFSAddStr("test content for max request duration") + + client := node.GatewayClient() + + // Fast request for local content should succeed (well within 500ms) + resp := client.Get("/ipfs/" + cid) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "test content for max request duration", resp.Body) + + // Request for non-existent content should timeout due to MaxRequestDuration + // This CID has no providers and will block during content routing + nonExistentCID := "bafkreif6lrhgz3fpiwypdk65qrqiey7svgpggruhbylrgv32l3izkqpsc4" + + // Create a client with a longer timeout than MaxRequestDuration + // to ensure we receive the gateway's 504 response + clientWithTimeout := &harness.HTTPClient{ + Client: &http.Client{ + Timeout: 5 * time.Second, + }, + BaseURL: client.BaseURL, + } + + resp = clientWithTimeout.Get("/ipfs/" + nonExistentCID) + assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "Expected 504 when request exceeds MaxRequestDuration") + }) + t.Run("MaxConcurrentRequests", func(t *testing.T) { t.Parallel() diff --git a/test/dependencies/go.mod b/test/dependencies/go.mod index 75bbdf72c96..fde70775943 100644 --- a/test/dependencies/go.mod +++ b/test/dependencies/go.mod @@ -135,7 +135,7 @@ require ( github.com/huin/goupnp v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/ipfs/bbloom v0.0.4 // indirect - github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c // indirect + github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2 // indirect github.com/ipfs/go-bitfield v1.1.0 // indirect github.com/ipfs/go-block-format v0.2.3 // indirect github.com/ipfs/go-cid v0.6.0 // indirect diff --git a/test/dependencies/go.sum b/test/dependencies/go.sum index 78d6acaefa4..b60efa9b803 100644 --- a/test/dependencies/go.sum +++ b/test/dependencies/go.sum @@ -294,8 +294,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= -github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c h1:mczpALnNzNhmggehO5Ehr9+Q8+NiJyKJfT4EPwi01d0= -github.com/ipfs/boxo v0.35.3-0.20251202220026-0842ad274a0c/go.mod h1:Abmp1if6bMQG87/0SQPIB9fkxJnZMLCt2nQw3yUZHH0= +github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2 h1:pRQYSSGnGQa921d8v0uhXg2BGzoSf9ndTWTlR7ImVoo= +github.com/ipfs/boxo v0.35.3-0.20260109213916-89dc184784f2/go.mod h1:Abmp1if6bMQG87/0SQPIB9fkxJnZMLCt2nQw3yUZHH0= github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA= github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk=