Repository: tus/tusd (Go)

Issue:
Potential Safeguard Omissions in `HttpHook.InvokeHook() (pkg/hooks/http)`: Context and Body Size Limit
**Description:** 

In the ```HttpHook.InvokeHook()``` function, the response body from the hook endpoint is read without:

- A ```context.Context``` for cancellation.
- Any size limit or streaming safeguards.

While it's reasonable to assume that the endpoint is trusted, implementing some lightweight safeguards can improve robustness and prevent misuse.

**Why this could be a concern:**

- If the endpoint is **slow or unresponsive**, the request might hang since no context is passed to ```http.NewRequest```.
- If the endpoint responds with a large or unexpected payload, using ```io.ReadAll``` without a size cap can lead to high memory usage or even a denial-of-service scenario.

**Suggestion for improvement:**

- Use ```http.NewRequestWithContext()``` with ```hookReq.Context``` or a derived context to support cancellation/timeouts.
- Wrap the response body with ```io.LimitReader()``` before passing it to ```io.ReadAll``` to prevent excessively large responses.
- Optionally, validate the ```Content-Type``` header to ensure it's ```application/json``` before unmarshalling.

Relevant code snippet:
```httpBody, err := io.ReadAll(httpRes.Body)```

Thanks for maintaining this project. I'm new to it, but happy to contribute and raise this in case it's helpful. Please let me know it I can clarify or improve this suggestion!




Discussion: Thanks for raising these valid points! I agree that tusd should introduce time and resource limits to protect against unbound resource usage.

> * Use `http.NewRequestWithContext()` with `hookReq.Context` or a derived context to support cancellation/timeouts.
> * Wrap the response body with `io.LimitReader()` before passing it to `io.ReadAll` to prevent excessively large responses.

Both sound good to me. We can have a default timeout and size limit while allowing users to overwrite the default limits via command line options, in case they need higher threshold for their applications.

The use of `LimitReader` might be problematic as it doesn't raise an error if the limit is reached. If a hook responds with a JSON payload that is too large, it will be truncated and raise an error about invalid JSON. That will be hard to debug. I suggest using a mechanism that raises a descriptive error if the size limit is reached or exceeded.

> * Optionally, validate the `Content-Type` header to ensure it's `application/json` before unmarshalling.

Would be possible as well, yes. I don't see any reason against this.

Would you be open to work on these improvements in a PR?
Thanks for the detailed feedback and suggestions! I’d be happy to work on these improvements and submit a PR. Please let me know if there’s anything specific you’d like me to keep in mind.
That's great! I'm looking forward to your contribution! The only things that come to my mind right now are adding the corresponding flags and updating the hook documentation accordingly.
Hi @Acconut ,

Thanks for the guidance! Just to confirm: what default response size limit are we expecting for the HTTP hook responses? 

I'm thinking 64 KB would be a reasonable default since the responses are typically small JSON objects, but I'd appreciate your input if you suggest a different value.

Thanks again!
In my experience, hook responses are usually small as they are just control messages which little verbosity. So I think we could even go as low as 5 KiB in the beginning. If people run into problems, we can always increase the default limit.
Hi @Acconut, I’ve opened a PR. Please have a look when you get a chance. Thanks!



Fix (patch):
diff --git a/cmd/tusd/cli/flags.go b/cmd/tusd/cli/flags.go
index d2e078921..abca02261 100644
--- a/cmd/tusd/cli/flags.go
+++ b/cmd/tusd/cli/flags.go
@@ -54,6 +54,8 @@ var Flags struct {
 	HttpHooksForwardHeaders          string
 	HttpHooksRetry                   int
 	HttpHooksBackoff                 time.Duration
+	HttpHooksTimeout                 time.Duration
+	HttpHooksSizeLimit               int64
 	GrpcHooksEndpoint                string
 	GrpcHooksRetry                   int
 	GrpcHooksBackoff                 time.Duration
@@ -169,6 +171,8 @@ func ParseFlags() {
 		f.StringVar(&Flags.HttpHooksForwardHeaders, "hooks-http-forward-headers", "", "List of HTTP request headers to be forwarded from the client request to the hook endpoint")
 		f.IntVar(&Flags.HttpHooksRetry, "hooks-http-retry", 3, "Number of times to retry on a 500 or network timeout")
 		f.DurationVar(&Flags.HttpHooksBackoff, "hooks-http-backoff", 1*time.Second, "Wait period before retrying each retry")
+		f.DurationVar(&Flags.HttpHooksTimeout, "hooks-http-timeout", 15*time.Second, "Timeout for the HTTP hook requests")
+		f.Int64Var(&Flags.HttpHooksSizeLimit, "hooks-http-size-limit", 5*1024, "Maximum size of the response body in bytes")
 	})
 
 	fs.AddGroup("gRPC hook options", func(f *flag.FlagSet) {
diff --git a/cmd/tusd/cli/hooks.go b/cmd/tusd/cli/hooks.go
index 92c7d6f27..2275c2b41 100644
--- a/cmd/tusd/cli/hooks.go
+++ b/cmd/tusd/cli/hooks.go
@@ -26,6 +26,8 @@ func getHookHandler(config *handler.Config) hooks.HookHandler {
 			MaxRetries:     Flags.HttpHooksRetry,
 			Backoff:        Flags.HttpHooksBackoff,
 			ForwardHeaders: strings.Split(Flags.HttpHooksForwardHeaders, ","),
+			Timeout:        Flags.HttpHooksTimeout,
+			SizeLimit:      Flags.HttpHooksSizeLimit,
 		}
 	} else if Flags.GrpcHooksEndpoint != "" {
 		printStartupLog("Using '%s' as the endpoint for gRPC hooks", Flags.GrpcHooksEndpoint)
diff --git a/docs/_advanced-topics/hooks.md b/docs/_advanced-topics/hooks.md
index fc392201a..2d4f2c0ad 100644
--- a/docs/_advanced-topics/hooks.md
+++ b/docs/_advanced-topics/hooks.md
@@ -150,7 +150,7 @@ Below you can find an annotated, JSON-ish encoded example of a hook response:
         // configure Cross-Origin Resource Sharing (CORS) to include your custom header in
         // Access-Control-Expose-Headers or otherwise browsers will block access to the custom
         // header. See https://tus.github.io/tusd/getting-started/configuration/#cross-origin-resource-sharing-cors
-        // for more details about tusd and CORS.  
+        // for more details about tusd and CORS.
         "Header": {
             "Content-Type": "application/json"
         },
@@ -295,7 +295,9 @@ The request body also includes all details about the request from the client to
 
 When the endpoint responds with a non-2XX status code, tusd interprets this as an internal failure. For the pre-create and pre-finish hook, it will stop the processing of the request and respond with a `500 Internal Server Error` to the client. For the other hooks, an error will be logged to tusd's logs, but not error response is sent to the client. Network errors and internal server errors from the hook endpoint will cause the request to be retried, as mentioned [below](#retries).
 
-When the endpoint responds with a 2XX status code, tusd reads the response body and parses it as a JSON-encoded hook response. This allows the hook to customize the HTTP response, reject and abort uploads.
+When the endpoint responds with a 2XX status code, tusd reads the response body and parses it as a JSON-encoded hook response. This allows the hook to customize the HTTP response, reject and abort uploads. Note that tusd requires the `Content-Type: application/json` header to be present in all hook responses.
+
+By default, tusd employs a default request timeout of 15s for all HTTP(S) hook to prevent hanging hooks and uploads. In addition, the response content is limited to 5 KiB by default. If you need longer execution time or larger content sizes, you can configure these limits using the `-hooks-http-timeout` and `-hooks-http-size-limit` flags. For detailed information on these flags, run `tusd -help`.
 
 A Python-based example is available at [github.com/tus/tusd/examples/hooks/http](https://github.com/tus/tusd/tree/main/examples/hooks/http).
 
@@ -353,7 +355,7 @@ $ tusd -hooks-plugin ./hook_handler
 
 ### Receiving and Validating User Data
 
-Clients can set custom [meta data values](https://tus.io/protocols/resumable-upload#upload-metadata) when starting an upload, such as the file name, file type, or any other associate data. These values are also available in each hook request for every handler type. The `pre-create` hook can be used to validate these values before an upload even begins. 
+Clients can set custom [meta data values](https://tus.io/protocols/resumable-upload#upload-metadata) when starting an upload, such as the file name, file type, or any other associate data. These values are also available in each hook request for every handler type. The `pre-create` hook can be used to validate these values before an upload even begins.
 
 For example, assume that every upload must belong to a specific user project. The upload client adds a `project_id` meta data field for each upload, describing which project the upload belongs to. The `pre-create` hook then takes the `project_id` from the hook request and validates it, ensuring that the value is present, belongs to an existing project, and that the user has access to this project. If the validation passes, the hook can return an empty hook response to indicate tusd that the upload should continue as normal. If the validation fails, the hook can instruct tusd to reject the upload and return a custom error response to the client. For example, this is a possible hook response:
 
diff --git a/examples/hooks/http/server.py b/examples/hooks/http/server.py
index 4f692126a..043897e04 100644
--- a/examples/hooks/http/server.py
+++ b/examples/hooks/http/server.py
@@ -71,6 +71,7 @@ def do_POST(self):
         # Send the data from the hook response as JSON output
         response_body = json.dumps(hook_response)
         self.send_response(200)
+        self.send_header('Content-Type', 'application/json')
         self.end_headers()
         self.wfile.write(response_body.encode())
 
diff --git a/pkg/hooks/http/http.go b/pkg/hooks/http/http.go
index 1a1d0a3bf..da3aa635a 100644
--- a/pkg/hooks/http/http.go
+++ b/pkg/hooks/http/http.go
@@ -6,9 +6,11 @@ package http
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"io"
+	"mime"
 	"net/http"
 	"time"
 
@@ -21,6 +23,8 @@ type HttpHook struct {
 	MaxRetries     int
 	Backoff        time.Duration
 	ForwardHeaders []string
+	Timeout        time.Duration
+	SizeLimit      int64
 
 	client *pester.Client
 }
@@ -45,7 +49,15 @@ func (h HttpHook) InvokeHook(hookReq hooks.HookRequest) (hookRes hooks.HookRespo
 		return hookRes, err
 	}
 
-	httpReq, err := http.NewRequest("POST", h.Endpoint, bytes.NewBuffer(jsonInfo))
+	ctx := hookReq.Event.Context
+	if ctx == nil {
+		ctx = context.Background()
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, h.Timeout)
+	defer cancel()
+
+	httpReq, err := http.NewRequestWithContext(ctx, "POST", h.Endpoint, bytes.NewBuffer(jsonInfo))
 	if err != nil {
 		return hookRes, err
 	}
@@ -66,7 +78,7 @@ func (h HttpHook) InvokeHook(hookReq hooks.HookRequest) (hookRes hooks.HookRespo
 	}
 	defer httpRes.Body.Close()
 
-	httpBody, err := io.ReadAll(httpRes.Body)
+	httpBody, err := io.ReadAll(io.LimitReader(httpRes.Body, h.SizeLimit+1))
 	if err != nil {
 		return hookRes, err
 	}
@@ -76,6 +88,23 @@ func (h HttpHook) InvokeHook(hookReq hooks.HookRequest) (hookRes hooks.HookRespo
 		return hookRes, fmt.Errorf("unexpected response code from hook endpoint (%d): %s", httpRes.StatusCode, string(httpBody))
 	}
 
+	if int64(len(httpBody)) > h.SizeLimit {
+		return hookRes, fmt.Errorf("hook response exceeded maximum size of %d bytes", h.SizeLimit)
+	}
+
+	contentType := httpRes.Header.Get("Content-Type")
+	if contentType == "" {
+		return hookRes, fmt.Errorf("hook response does not contain the 'Content-Type: application/json' header")
+	}
+
+	mediaType, _, err := mime.ParseMediaType(contentType)
+	if err != nil {
+		return hookRes, fmt.Errorf("failed to parse Content-Type header: %w", err)
+	}
+	if mediaType != "application/json" {
+		return hookRes, fmt.Errorf("expected hook response Content-Type to be application/json, but got '%s'", contentType)
+	}
+
 	if err = json.Unmarshal(httpBody, &hookRes); err != nil {
 		return hookRes, fmt.Errorf("failed to parse hook response: %w", err)
 	}