Repository: projectdiscovery/nuclei

Issue:
[BUG] ...HTTP Request Timeout Hardcoded to 60 Seconds Despite CLI and Template Configuration
### Is there an existing issue for this?

- [x] I have searched the existing issues.

### Current Behavior

## Summary

HTTP requests in Nuclei templates are hardcoded to timeout after exactly 60 seconds, ignoring both the CLI `--timeout` flag and the template-level `@timeout` directive when the timeout exceeds 60 seconds.



## Bug Description

When making HTTP requests that take longer than 60 seconds to complete, Nuclei throws a timeout error regardless of:
1. CLI `--timeout` flag value
2. Template `@timeout` directive value
3. Both being set to values greater than 60 seconds

The timeout always occurs at exactly 60 seconds, suggesting a hardcoded limit in the HTTP client implementation.



## Critical Debug Evidence

The debug output clearly shows the configuration is correct but ignored:

1. **Template timeout variable set properly**: `"request_timeout": "300s"`
2. **CLI timeout set**: `--timeout 70`
3. **Sleep duration requested**: `"time_to_sleep": "30"` (well under any reasonable timeout)
4. **Actual timeout**: Exactly 60 seconds (`real 1m0.731s`)
5. **Request properly sent**: HTTP request is correctly formatted and transmitted

## Error Messages

```
[WRN] [debug-timeout-issue] Could not execute request for https://sleep.skymaster.io/: 
cause="context deadline exceeded (Client.Timeout exceeded while awaiting headers)" 
chain="got err while executing https://sleep.skymaster.io/?time_to_sleep=30"
```

## Impact

This bug prevents Nuclei from being used with:
- Slow APIs that require more than 60 seconds to respond
- Large file downloads
- APIs with intentional delays for rate limiting
- Long-running operations (like scan result exports in security tools)

## Root Cause Analysis

The issue appears to be in Nuclei's HTTP client implementation where a hardcoded 60-second timeout overrides user-configured timeouts. This could be:

1. Go's `http.Client.Timeout` being set to 60 seconds internally
2. A context timeout being applied at the HTTP protocol level
3. A deadline being set in the HTTP transport layer

## Workaround

Currently, there is no known workaround for this issue. The timeout cannot be extended beyond 60 seconds through any configuration method.

## Additional Information

- The same template works fine for requests under 60 seconds
- The issue occurs with both `raw` and standard HTTP request formats
- The issue persists across different target hosts
- Using `unsafe: true` does not resolve the issue

## Test Environment

**Test endpoint used**: `https://sleep.skymaster.io/` - A public service that sleeps for the specified number of seconds before responding, useful for timeout testing.

### Expected Behavior


- CLI `--timeout 70` should allow requests to run for 70 seconds
- Template `@timeout: 300s` should allow requests to run for 300 seconds  
- The larger of the two values should take precedence
- A 30-second sleep request should complete successfully within the configured timeouts

## Actual Behavior

- All HTTP requests timeout after exactly 60 seconds regardless of configuration
- Error message: `context deadline exceeded (Client.Timeout exceeded while awaiting headers)`
- Both CLI and template timeout directives are ignored for values > 60 seconds
- Even requests that should complete in 30 seconds fail due to the hardcoded 60-second limit


### Steps To Reproduce

## Steps to Reproduce

### Test Setup
Created a test template that calls a sleep endpoint to simulate long-running requests:

**File: `timeout-debug.yaml`**
```yaml
id: debug-timeout-issue

info:
  name: Debug Timeout Issue — debug extended timeout
  author: Maoz Zadok
  severity: info
  tags: debug,timeout

variables:
  time_to_sleep: "61"
  request_timeout: "300s"

http:
  - raw:
      - |
        @timeout: {{request_timeout}}
        GET /?time_to_sleep={{time_to_sleep}} HTTP/1.1
        Host: {{Hostname}}
        X-Requested-With: curl
        Accept: application/json
    matchers:
      - type: status
        status: [200]
    extractors:
      - type: dsl
        name: scan_data
        part: body
        dsl:
          - body
```

### Test Commands

**Test 1: 1 second sleep (SUCCESS)**
```bash
time nuclei -t timeout-debug.yaml -var time_to_sleep=1 -u https://sleep.skymaster.io/ --timeout 70
```
**Result**: ✅ Completes successfully in ~1.4 seconds

**Test 2: 30 seconds sleep (FAILURE)**
```bash
time nuclei -t timeout-debug.yaml -var time_to_sleep=30 -u https://sleep.skymaster.io/ --debug -v -svd --timeout 70
```

**Complete Command Output:**
```
$ time nuclei -t timeout-debug.yaml -var time_to_sleep=30 -u https://sleep.skymaster.io/ --debug -v -svd --timeout 70

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.4.10

                projectdiscovery.io

[VER] Started metrics server at localhost:9092
[INF] Current nuclei version: v3.4.10 (latest)
[INF] Current nuclei-templates version: v10.3.1 (latest)
[WRN] Scan results upload to cloud is disabled.
[INF] New templates added in latest release: 119
[INF] Templates loaded for current scan: 1
[WRN] Loading 1 unsigned templates for scan. Use with caution.
[INF] Targets loaded for current scan: 1
[DBG] HTTP Protocol request variables: map[string]interface {}:18 {
  "BaseURL": "https://sleep.skymaster.io/",
  "Hostname": "sleep.skymaster.io",
  "Port": "443",
  "time_to_sleep": "30",
  "DN": "skymaster",
  "File": "/",
  "Path": "/",
  "RDN": "skymaster.io",
  "RootURL": "https://sleep.skymaster.io",
  "Scheme": "https",
  "FQDN": "sleep.skymaster.io",
  "Host": "sleep.skymaster.io",
  "Input": "https://sleep.skymaster.io/",
  "TLD": "io",
  "ip": "",
  "Query": "",
  "SD": "sleep",
  "request_timeout": "300s",
}
[INF] [debug-timeout-issue] Dumped HTTP request for https://sleep.skymaster.io/?time_to_sleep=30

GET /?time_to_sleep=30 HTTP/1.1
Host: sleep.skymaster.io
User-Agent: Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Safari/106.0 Safari/537.36
Connection: close
Accept: application/json
X-Requested-With: curl
Accept-Encoding: gzip

[WRN] [debug-timeout-issue] Could not execute request for https://sleep.skymaster.io/: cause="context deadline exceeded (Client.Timeout exceeded while awaiting headers)" chain="got err while executing https://sleep.skymaster.io/?time_to_sleep=30"
[INF] Scan completed in 1m. No results found.

real    1m0.731s
user    0m0.201s
sys     0m0.090s
```

**Result**: ❌ Times out after exactly 60 seconds despite:
- CLI `--timeout 70` (should allow 70 seconds)
- Template variable `"request_timeout": "300s"` (should allow 300 seconds)
- Template variable `"time_to_sleep": "30"` (requesting only 30 seconds sleep)


### Relevant log output

```shell
[WRN] [debug-timeout-issue] Could not execute request for https://sleep.skymaster.io/: cause="context deadline exceeded (Client.Timeout exceeded while awaiting headers)" chain="got err while executing https://sleep.skymaster.io/?time_to_sleep=30"
[INF] Scan completed in 1m. No results found.
```

### Environment

```markdown
- OS: Ubuntu-24.04/ Mac 
- Nuclei: v3.4.10
- Go: using binary
```

### Anything else?

The timeout test was tested with code block, and it seems that the timeout work as expected, I assume its been ignored on HTTP request only

```
code:
  - engine:
      - bash
    source: |
        #!/bin/bash
        sleep ${time_to_sleep}
        echo {"status":"completed","message":"Slept for ${time_to_sleep} seconds"}
```

Discussion:
@dwisiswant0: Thanks for reporting this, @maozza!

Looks like it’s capped at 30s. The reason it takes ~1m is `retryablehttp-go` pkg defaults to 5 retries _(though it only triggers on certain errors)_. A timeout probably causes one retry = 2 attempts total.

A PR fix with a detailed RCA soon.

Solution patch:
diff --git a/pkg/protocols/http/httpclientpool/clientpool.go b/pkg/protocols/http/httpclientpool/clientpool.go
index 4fa0790a..14f4c8dc 100644
--- a/pkg/protocols/http/httpclientpool/clientpool.go
+++ b/pkg/protocols/http/httpclientpool/clientpool.go
@@ -214,6 +214,10 @@ func wrappedGet(options *types.Options, configuration *Configuration) (*retryabl
 
 	retryableHttpOptions.RetryWaitMax = 10 * time.Second
 	retryableHttpOptions.RetryMax = options.Retries
+	retryableHttpOptions.Timeout = time.Duration(options.Timeout) * time.Second
+	if configuration.ResponseHeaderTimeout > 0 && configuration.ResponseHeaderTimeout > retryableHttpOptions.Timeout {
+		retryableHttpOptions.Timeout = configuration.ResponseHeaderTimeout
+	}
 	redirectFlow := configuration.RedirectFlow
 	maxRedirects := configuration.MaxRedirects
 
@@ -261,6 +265,11 @@ func wrappedGet(options *types.Options, configuration *Configuration) (*retryabl
 	if configuration.ResponseHeaderTimeout != 0 {
 		responseHeaderTimeout = configuration.ResponseHeaderTimeout
 	}
+
+	if responseHeaderTimeout < retryableHttpOptions.Timeout {
+		responseHeaderTimeout = retryableHttpOptions.Timeout
+	}
+
 	if configuration.Connection != nil && configuration.Connection.CustomMaxTimeout > 0 {
 		responseHeaderTimeout = configuration.Connection.CustomMaxTimeout
 	}
diff --git a/pkg/protocols/http/request.go b/pkg/protocols/http/request.go
index 47d795f9..c538686e 100644
--- a/pkg/protocols/http/request.go
+++ b/pkg/protocols/http/request.go
@@ -825,6 +825,7 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
 					connConfiguration := request.connConfiguration.Clone()
 					modifiedConfig = connConfiguration
 				}
+
 				modifiedConfig.ResponseHeaderTimeout = updatedTimeout.Timeout
 			}
 
diff --git a/pkg/protocols/http/request_annotations.go b/pkg/protocols/http/request_annotations.go
index 217e21a3..c87e543c 100644
--- a/pkg/protocols/http/request_annotations.go
+++ b/pkg/protocols/http/request_annotations.go
@@ -29,7 +29,9 @@ var (
 	reTimeoutAnnotation = regexp.MustCompile(`(?m)^@timeout:\s*(.+)\s*$`)
 	// @once sets the request to be executed only once for a specific URL
 	reOnceAnnotation = regexp.MustCompile(`(?m)^@once\s*$`)
-
+	// maxAnnotationTimeout is the maximum timeout allowed for @timeout
+	// annotations to prevent DoS attacks via extremely large timeout values.
+	maxAnnotationTimeout = 5 * time.Minute
 	// ErrTimeoutAnnotationDeadline is the error returned when a specific amount of time was exceeded for a request
 	// which was allotted using @timeout annotation this usually means that vulnerability was not found
 	// in rare case it could also happen due to network congestion
@@ -129,16 +131,17 @@ func (r *Request) parseAnnotations(rawRequest string, request *retryablehttp.Req
 
 		if duration := reTimeoutAnnotation.FindStringSubmatch(rawRequest); len(duration) > 0 {
 			value := strings.TrimSpace(duration[1])
-			if parsed, err := time.ParseDuration(value); err == nil {
-				// to avoid dos via timeout request annotation in http template we set it to maximum of 2 minutes
-				if parsed > 2*time.Minute {
-					parsed = 2 * time.Minute
+			if parsedTimeout, err := time.ParseDuration(value); err == nil {
+				// Cap at maximum allowed timeout to prevent DoS via extremely large timeout values
+				if parsedTimeout > maxAnnotationTimeout {
+					parsedTimeout = maxAnnotationTimeout
 				}
+
 				//nolint:govet // cancelled automatically by withTimeout
 				// global timeout is overridden by annotation by replacing context
-				ctx, overrides.cancelFunc = context.WithTimeoutCause(context.TODO(), parsed, ErrTimeoutAnnotationDeadline)
+				ctx, overrides.cancelFunc = context.WithTimeoutCause(context.TODO(), parsedTimeout, ErrTimeoutAnnotationDeadline)
 				// add timeout value to context
-				ctx = context.WithValue(ctx, httpclientpool.WithCustomTimeout{}, httpclientpool.WithCustomTimeout{Timeout: parsed})
+				ctx = context.WithValue(ctx, httpclientpool.WithCustomTimeout{}, httpclientpool.WithCustomTimeout{Timeout: parsedTimeout})
 				request = request.Clone(ctx)
 			}
 		} else {
diff --git a/pkg/types/types.go b/pkg/types/types.go
index 08ed924f..daed0190 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -742,12 +742,12 @@ func (tv *Timeouts) ApplyDefaults() {
 	if tv.TcpReadTimeout == 0 {
 		tv.TcpReadTimeout = 5 * time.Second
 	}
-	if tv.HttpResponseHeaderTimeout == 0 {
-		tv.HttpResponseHeaderTimeout = 10 * time.Second
-	}
 	if tv.HttpTimeout == 0 {
 		tv.HttpTimeout = 3 * tv.DialTimeout
 	}
+	if tv.HttpResponseHeaderTimeout < tv.HttpTimeout {
+		tv.HttpResponseHeaderTimeout = tv.HttpTimeout
+	}
 	if tv.JsCompilerExecutionTimeout == 0 {
 		tv.JsCompilerExecutionTimeout = 2 * tv.DialTimeout
 	}



Test patch:
diff --git a/pkg/protocols/http/request_annotations_test.go b/pkg/protocols/http/request_annotations_test.go
index 778a0cb7..e8899d2f 100644
--- a/pkg/protocols/http/request_annotations_test.go
+++ b/pkg/protocols/http/request_annotations_test.go
@@ -4,12 +4,26 @@ import (
 	"context"
 	"net/http"
 	"testing"
+	"time"
 
+	"github.com/projectdiscovery/gologger"
+	"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
 	"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
+	"github.com/projectdiscovery/nuclei/v3/pkg/testutils"
 	"github.com/projectdiscovery/retryablehttp-go"
 	"github.com/stretchr/testify/require"
 )
 
+func getExecuterOptions(t *testing.T) *protocols.ExecutorOptions {
+	t.Helper()
+
+	options := testutils.DefaultOptions.Copy()
+	options.Logger = &gologger.Logger{}
+	testutils.Init(options)
+
+	return testutils.NewMockExecuterOptions(options, nil)
+}
+
 func TestRequestParseAnnotationsSNI(t *testing.T) {
 	t.Run("compliant-SNI-value", func(t *testing.T) {
 		req := &Request{connConfiguration: &httpclientpool.Configuration{}}
@@ -44,6 +58,7 @@ func TestRequestParseAnnotationsSNI(t *testing.T) {
 func TestRequestParseAnnotationsTimeout(t *testing.T) {
 	t.Run("positive", func(t *testing.T) {
 		request := &Request{
+			options:           getExecuterOptions(t),
 			connConfiguration: &httpclientpool.Configuration{NoTimeout: true},
 		}
 		rawRequest := `@timeout: 2s
@@ -56,12 +71,91 @@ func TestRequestParseAnnotationsTimeout(t *testing.T) {
 		overrides, modified := request.parseAnnotations(rawRequest, httpReq)
 		require.NotNil(t, overrides.cancelFunc, "could not initialize valid cancel function")
 		require.True(t, modified, "could not get correct modified value")
-		_, deadlined := overrides.request.Context().Deadline()
+
+		// Verify context has deadline
+		deadline, deadlined := overrides.request.Context().Deadline()
+		require.True(t, deadlined, "could not get set request deadline")
+
+		// Verify the timeout value is stored in context
+		customTimeout, ok := overrides.request.Context().Value(httpclientpool.WithCustomTimeout{}).(httpclientpool.WithCustomTimeout)
+		require.True(t, ok, "custom timeout not found in context")
+		require.Equal(t, 2*time.Second, customTimeout.Timeout, "timeout value mismatch")
+
+		// Verify deadline is approximately 2 seconds from now
+		expectedDeadline := time.Now().Add(2 * time.Second)
+		require.WithinDuration(t, expectedDeadline, deadline, 100*time.Millisecond, "deadline not set correctly")
+	})
+
+	t.Run("large-timeout", func(t *testing.T) {
+		request := &Request{
+			options:           getExecuterOptions(t),
+			connConfiguration: &httpclientpool.Configuration{NoTimeout: true},
+		}
+
+		// Request a timeout of 10 minutes - should be capped at 5 minutes
+		rawRequest := `@timeout: 10m
+		GET / HTTP/1.1
+		Host: {{Hostname}}`
+
+		httpReq, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
+		require.Nil(t, err, "could not create http request")
+
+		overrides, modified := request.parseAnnotations(rawRequest, httpReq)
+		require.NotNil(t, overrides.cancelFunc, "could not initialize valid cancel function")
+		require.True(t, modified, "could not get correct modified value")
+
+		// Verify context has deadline
+		deadline, deadlined := overrides.request.Context().Deadline()
 		require.True(t, deadlined, "could not get set request deadline")
+
+		// Verify the timeout was capped at 5 minutes (not 10 minutes)
+		customTimeout, ok := overrides.request.Context().Value(httpclientpool.WithCustomTimeout{}).(httpclientpool.WithCustomTimeout)
+		require.True(t, ok, "custom timeout not found in context")
+
+		require.Equal(t, 5*time.Minute, customTimeout.Timeout, "timeout should be capped at 5 minutes")
+		require.Less(t, customTimeout.Timeout, 10*time.Minute, "timeout should be less than requested 10 minutes")
+
+		// Verify deadline matches the capped timeout
+		expectedDeadline := time.Now().Add(5 * time.Minute)
+		require.WithinDuration(t, expectedDeadline, deadline, 100*time.Millisecond, "deadline not set to capped timeout")
+	})
+
+	t.Run("below-cap-timeout", func(t *testing.T) {
+		request := &Request{
+			options:           getExecuterOptions(t),
+			connConfiguration: &httpclientpool.Configuration{NoTimeout: true},
+		}
+
+		// Request a timeout of 2 minutes - should be allowed (below 5 minute cap)
+		rawRequest := `@timeout: 2m
+		GET / HTTP/1.1
+		Host: {{Hostname}}`
+
+		httpReq, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
+		require.Nil(t, err, "could not create http request")
+
+		overrides, modified := request.parseAnnotations(rawRequest, httpReq)
+		require.NotNil(t, overrides.cancelFunc, "could not initialize valid cancel function")
+		require.True(t, modified, "could not get correct modified value")
+
+		// Verify context has deadline
+		deadline, deadlined := overrides.request.Context().Deadline()
+		require.True(t, deadlined, "could not get set request deadline")
+
+		// Verify the timeout is NOT capped - should be 2 minutes
+		customTimeout, ok := overrides.request.Context().Value(httpclientpool.WithCustomTimeout{}).(httpclientpool.WithCustomTimeout)
+		require.True(t, ok, "custom timeout not found in context")
+
+		require.Equal(t, 2*time.Minute, customTimeout.Timeout, "timeout should be the requested 2 minutes")
+
+		// Verify deadline matches the requested timeout
+		expectedDeadline := time.Now().Add(2 * time.Minute)
+		require.WithinDuration(t, expectedDeadline, deadline, 100*time.Millisecond, "deadline not set to requested timeout")
 	})
 
 	t.Run("negative", func(t *testing.T) {
 		request := &Request{
+			options:           getExecuterOptions(t),
 			connConfiguration: &httpclientpool.Configuration{},
 		}
 		rawRequest := `GET / HTTP/1.1