Skip to content

Commit 85e2354

Browse files
committed
fix(sources/virustotal): cap pagination + clearer 429 message
The free VirusTotal API returns 40 results per page and grants 500 requests/day. Following the cursor blindly on a single popular root domain can therefore exhaust the daily quota in one invocation, after which every other VT call in the run gets HTTP 429 wrapped as a generic "unexpected status code 429" with no hint that the cause is the free tier (#1718). Cap the pagination at 10 pages (~400 subdomains) by default so a single domain can no longer monopolise the quota, and surface a dedicated "virustotal free-tier quota exhausted" error when 429 is observed so operators know to either supply an enterprise key or accept the cap.
1 parent 0ed1b0b commit 85e2354

2 files changed

Lines changed: 184 additions & 3 deletions

File tree

pkg/subscraping/sources/virustotal/virustotal.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@ package virustotal
44
import (
55
"context"
66
"fmt"
7+
"net/http"
78
"time"
89

910
jsoniter "github.com/json-iterator/go"
1011

1112
"github.com/projectdiscovery/subfinder/v2/pkg/subscraping"
1213
)
1314

15+
// VirusTotal's public API caps subdomain pages at 40 results and the free
16+
// tier allows 500 requests/day across all of the user's runs. Following a
17+
// long cursor on a single popular root domain can therefore exhaust the
18+
// entire daily quota in one invocation (see #1718). Cap the pagination by
19+
// default so a single domain can't starve the rest of the run.
20+
const (
21+
pageLimit = 40
22+
defaultMaxPages = 10
23+
)
24+
1425
type response struct {
1526
Data []Object `json:"data"`
1627
Meta Meta `json:"meta"`
@@ -52,20 +63,24 @@ func (s *Source) Run(ctx context.Context, domain string, session *subscraping.Se
5263
return
5364
}
5465
var cursor = ""
55-
for {
66+
for page := 0; page < defaultMaxPages; page++ {
5667
select {
5768
case <-ctx.Done():
5869
return
5970
default:
6071
}
61-
var url = fmt.Sprintf("https://www.virustotal.com/api/v3/domains/%s/subdomains?limit=40", domain)
72+
var url = fmt.Sprintf("https://www.virustotal.com/api/v3/domains/%s/subdomains?limit=%d", domain, pageLimit)
6273
if cursor != "" {
6374
url = fmt.Sprintf("%s&cursor=%s", url, cursor)
6475
}
6576
s.requests++
6677
resp, err := session.Get(ctx, url, "", map[string]string{"x-apikey": randomApiKey})
6778
if err != nil {
68-
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
79+
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
80+
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: fmt.Errorf("virustotal free-tier quota exhausted (HTTP 429); some subdomains for %s may be missing", domain)}
81+
} else {
82+
results <- subscraping.Result{Source: s.Name(), Type: subscraping.Error, Error: err}
83+
}
6984
s.errors++
7085
session.DiscardHTTPResponse(resp)
7186
return
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package virustotal
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math"
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
14+
"github.com/projectdiscovery/ratelimit"
15+
"github.com/projectdiscovery/subfinder/v2/pkg/subscraping"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// rewriteTransport sends every request to the test server's host while
21+
// preserving the original path/query, so the source's hardcoded
22+
// www.virustotal.com URL still resolves to our handler.
23+
type rewriteTransport struct {
24+
target *url.URL
25+
}
26+
27+
func (r *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
28+
req.URL.Scheme = r.target.Scheme
29+
req.URL.Host = r.target.Host
30+
return http.DefaultTransport.RoundTrip(req)
31+
}
32+
33+
func newTestSession(t *testing.T, server *httptest.Server) *subscraping.Session {
34+
t.Helper()
35+
target, err := url.Parse(server.URL)
36+
require.NoError(t, err)
37+
38+
mrl, err := ratelimit.NewMultiLimiter(context.Background(), &ratelimit.Options{
39+
Key: "virustotal",
40+
IsUnlimited: false,
41+
MaxCount: math.MaxInt32,
42+
Duration: time.Millisecond,
43+
})
44+
require.NoError(t, err)
45+
46+
return &subscraping.Session{
47+
Client: &http.Client{Transport: &rewriteTransport{target: target}, Timeout: 5 * time.Second},
48+
MultiRateLimiter: mrl,
49+
}
50+
}
51+
52+
func runSource(t *testing.T, source *Source, session *subscraping.Session, domain string) (subs []string, errs []error) {
53+
t.Helper()
54+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
55+
defer cancel()
56+
ctxWithValue := context.WithValue(ctx, subscraping.CtxSourceArg, "virustotal")
57+
58+
for r := range source.Run(ctxWithValue, domain, session) {
59+
switch r.Type {
60+
case subscraping.Subdomain:
61+
subs = append(subs, r.Value)
62+
case subscraping.Error:
63+
errs = append(errs, r.Error)
64+
}
65+
}
66+
return
67+
}
68+
69+
func TestVirustotalSource_Metadata(t *testing.T) {
70+
source := &Source{}
71+
assert.Equal(t, "virustotal", source.Name())
72+
assert.True(t, source.IsDefault())
73+
assert.True(t, source.HasRecursiveSupport())
74+
assert.True(t, source.NeedsKey())
75+
}
76+
77+
func TestVirustotalSource_NoApiKey(t *testing.T) {
78+
source := &Source{}
79+
80+
ctx := context.Background()
81+
mrl, _ := ratelimit.NewMultiLimiter(ctx, &ratelimit.Options{
82+
Key: "virustotal",
83+
IsUnlimited: false,
84+
MaxCount: math.MaxInt32,
85+
Duration: time.Millisecond,
86+
})
87+
session := &subscraping.Session{Client: http.DefaultClient, MultiRateLimiter: mrl}
88+
ctxWithValue := context.WithValue(ctx, subscraping.CtxSourceArg, "virustotal")
89+
90+
var count int
91+
for range source.Run(ctxWithValue, "example.com", session) {
92+
count++
93+
}
94+
assert.Equal(t, 0, count, "expected no results without an api key")
95+
assert.Equal(t, 0, source.Statistics().Requests)
96+
}
97+
98+
// Pagination must stop after defaultMaxPages even when the API keeps
99+
// returning a non-empty cursor; otherwise a single root domain can drain
100+
// the free-tier 500 req/day quota (see #1718).
101+
func TestVirustotalSource_PaginationCappedOnFreeTier(t *testing.T) {
102+
var hits int32
103+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104+
atomic.AddInt32(&hits, 1)
105+
w.Header().Set("Content-Type", "application/json")
106+
// Always return one subdomain plus a non-empty cursor so the source
107+
// would loop forever without the page cap.
108+
fmt.Fprintf(w, `{"data":[{"id":"sub%d.example.com"}],"meta":{"cursor":"next-%d"}}`, hits, hits)
109+
}))
110+
defer server.Close()
111+
112+
source := &Source{}
113+
source.AddApiKeys([]string{"test-key"})
114+
115+
subs, errs := runSource(t, source, newTestSession(t, server), "example.com")
116+
117+
assert.Empty(t, errs, "no errors expected on the happy path")
118+
assert.Len(t, subs, defaultMaxPages, "should yield exactly one subdomain per capped page")
119+
assert.Equal(t, defaultMaxPages, source.Statistics().Requests, "request count must be capped at defaultMaxPages")
120+
assert.Equal(t, int32(defaultMaxPages), atomic.LoadInt32(&hits))
121+
}
122+
123+
// Cursor exhaustion before the cap must not waste an extra request.
124+
func TestVirustotalSource_StopsWhenCursorEmpty(t *testing.T) {
125+
var hits int32
126+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127+
n := atomic.AddInt32(&hits, 1)
128+
w.Header().Set("Content-Type", "application/json")
129+
if n == 1 {
130+
fmt.Fprint(w, `{"data":[{"id":"a.example.com"}],"meta":{"cursor":"more"}}`)
131+
return
132+
}
133+
fmt.Fprint(w, `{"data":[{"id":"b.example.com"}],"meta":{"cursor":""}}`)
134+
}))
135+
defer server.Close()
136+
137+
source := &Source{}
138+
source.AddApiKeys([]string{"test-key"})
139+
140+
subs, errs := runSource(t, source, newTestSession(t, server), "example.com")
141+
142+
assert.Empty(t, errs)
143+
assert.ElementsMatch(t, []string{"a.example.com", "b.example.com"}, subs)
144+
assert.Equal(t, 2, source.Statistics().Requests)
145+
}
146+
147+
// On HTTP 429 (free-tier quota exhausted) the source must surface a
148+
// human-readable error mentioning the quota, not a generic
149+
// "unexpected status code 429" string.
150+
func TestVirustotalSource_RateLimitedReturnsActionableError(t *testing.T) {
151+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+
w.WriteHeader(http.StatusTooManyRequests)
153+
}))
154+
defer server.Close()
155+
156+
source := &Source{}
157+
source.AddApiKeys([]string{"test-key"})
158+
159+
_, errs := runSource(t, source, newTestSession(t, server), "example.com")
160+
161+
require.Len(t, errs, 1, "exactly one error expected on 429")
162+
assert.Contains(t, errs[0].Error(), "quota exhausted")
163+
assert.Contains(t, errs[0].Error(), "429")
164+
assert.Equal(t, 1, source.Statistics().Requests, "must not retry past the first 429")
165+
assert.Equal(t, 1, source.Statistics().Errors)
166+
}

0 commit comments

Comments
 (0)