Skip to content

Commit 83d3128

Browse files
h0tak88rclaude
andcommitted
fix(programs): stop stale/empty scope for searched programs (rate-limit cache poisoning)
A HackerOne program (ryan-bbp) updated its scope, but the Programs page kept showing TARGETS="—" because: - fetchH1ScopeSummary never checked the HTTP status, so a 429 (the background warmer enriching ~800 H1 programs hammers the API) or 403 became an empty summary that was then cached for 15 minutes — hiding the program's real scope. - the warm program list marks every program _scope_loaded without ever re-fetching, so a program that got an empty/rate-limited enrichment never recovered. Fixes: - fetchH1ScopeSummary returns (summary, ok); ok=false on request error or non-200, and callers no longer cache a failed fetch (so a transient 429/403 can't stick for the TTL). - /api/scope/program-summaries accepts force=true to bypass the 15-min scope cache. - Programs search now live-fetches scope (force=true) for the matched programs that have no scope loaded — so searching "ryan-bbp" pulls its CURRENT scope (10 targets, latest update) instead of the stale warm-cache "—". Bounded to ≤25 matches, debounced 350ms, gated by a _scope_fresh flag so it never re-hammers; Refresh now resets it. Verified: go build/vet, node --check, focused review (caching, force path, debounce, loadSeq guard, no re-hammer loop, no XSS) — no issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8b8c6d6 commit 83d3128

3 files changed

Lines changed: 85 additions & 18 deletions

File tree

internal/api/programs_api.go

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type ProgramStats struct {
5353

5454
type programScopeRequest struct {
5555
Programs []programScopeRequestItem `json:"programs"`
56+
Force bool `json:"force"` // bypass the scope cache (manual Refresh)
5657
}
5758

5859
type programScopeRequestItem struct {
@@ -270,11 +271,13 @@ func apiProgramScopeSummaries(c *gin.Context) {
270271
}
271272

272273
cacheKey := programScopeCacheKey(item.Platform, item.Handle)
273-
if summary, ok := getCachedProgramScope(cacheKey); ok {
274-
mu.Lock()
275-
summaries[cacheKey] = summary
276-
mu.Unlock()
277-
continue
274+
if !req.Force {
275+
if summary, ok := getCachedProgramScope(cacheKey); ok {
276+
mu.Lock()
277+
summaries[cacheKey] = summary
278+
mu.Unlock()
279+
continue
280+
}
278281
}
279282

280283
wg.Add(1)
@@ -288,8 +291,7 @@ func apiProgramScopeSummaries(c *gin.Context) {
288291
switch item.Platform {
289292
case "h1", "hackerone":
290293
if h1Auth != "" {
291-
summary = fetchH1ScopeSummary(item.Handle, h1Auth)
292-
fetched = true
294+
summary, fetched = fetchH1ScopeSummary(item.Handle, h1Auth)
293295
}
294296
case "bc", "bugcrowd":
295297
if bcToken != "" {
@@ -300,6 +302,8 @@ func apiProgramScopeSummaries(c *gin.Context) {
300302

301303
summary.Platform = item.Platform
302304
summary.Handle = item.Handle
305+
// Only cache a successful fetch — caching a rate-limited/empty result would
306+
// hide the program's real scope until the TTL expires.
303307
if fetched {
304308
setCachedProgramScope(cacheKey, summary)
305309
}
@@ -476,33 +480,45 @@ func enrichH1ScopeCounts(programs []ProgramSummary, auth string) {
476480
defer wg.Done()
477481
defer func() { <-sem }()
478482

479-
summary := fetchH1ScopeSummary(p.Handle, auth)
480-
p.ScopeTargets = summary.ScopeTargets
481-
p.LatestTarget = summary.LatestTarget
482-
p.LatestTargetUpdatedAt = summary.LatestTargetUpdatedAt
483-
p.LatestTargetBrief = summary.LatestTargetBrief
483+
summary, ok := fetchH1ScopeSummary(p.Handle, auth)
484+
if ok {
485+
p.ScopeTargets = summary.ScopeTargets
486+
p.LatestTarget = summary.LatestTarget
487+
p.LatestTargetUpdatedAt = summary.LatestTargetUpdatedAt
488+
p.LatestTargetBrief = summary.LatestTargetBrief
489+
}
484490
}(&programs[i])
485491
}
486492
wg.Wait()
487493
}
488494

489-
func fetchH1ScopeSummary(handle, auth string) ProgramSummary {
495+
// fetchH1ScopeSummary returns the scope summary and an ok flag. ok is false when the
496+
// request fails or H1 returns a non-200 (e.g. 429 rate-limit / 403) — the caller must
497+
// NOT cache a !ok result, otherwise a transient failure would hide a program's real
498+
// scope (showing "—") for the whole cache TTL.
499+
func fetchH1ScopeSummary(handle, auth string) (ProgramSummary, bool) {
490500
url := fmt.Sprintf("https://api.hackerone.com/v1/hackers/programs/%s/structured_scopes?page%%5Bsize%%5D=100", handle)
491501
req, err := http.NewRequest("GET", url, nil)
492502
if err != nil {
493-
return ProgramSummary{}
503+
return ProgramSummary{}, false
494504
}
495505
req.Header.Set("Accept", "application/json")
496506
req.Header.Set("Authorization", "Basic "+auth)
497507

498508
client := &http.Client{Timeout: 15 * time.Second}
499509
resp, err := client.Do(req)
500510
if err != nil {
501-
return ProgramSummary{}
511+
return ProgramSummary{}, false
502512
}
503513
body, _ := io.ReadAll(resp.Body)
504514
resp.Body.Close()
505515

516+
if resp.StatusCode != http.StatusOK {
517+
// 429 (rate-limited by hammering the API), 403, 401, etc. — signal failure so
518+
// the empty result isn't cached and the next refresh retries.
519+
return ProgramSummary{}, false
520+
}
521+
506522
scopes := gjson.Get(string(body), "data")
507523
summary := ProgramSummary{Platform: "h1", Handle: handle}
508524
for _, s := range scopes.Array() {
@@ -518,7 +534,7 @@ func fetchH1ScopeSummary(handle, auth string) ProgramSummary {
518534
}
519535
}
520536
}
521-
return summary
537+
return summary, true
522538
}
523539

524540
// ─────────────────────────────────────────────────────────────────────────────

internal/api/ui/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ <h1 class="view-title">Programs</h1>
424424
<option value="bc">Bugcrowd</option>
425425
<option value="it">Intigriti</option>
426426
</select>
427-
<input class="search-input" id="programs-search" placeholder="Search programs…" autocomplete="off" oninput="window.ProgramsPage.renderPrograms()" />
427+
<input class="search-input" id="programs-search" placeholder="Search programs…" autocomplete="off" oninput="window.ProgramsPage.onSearch()" />
428428
</div>
429429

430430
<div id="programs-stats-bar" style="display:flex;gap:16px;margin-bottom:12px;font-size:12px;color:rgba(255,255,255,0.5);"></div>

internal/api/ui/pages/programs.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,55 @@
184184
renderStats(false);
185185
}
186186

187+
function matchesProgram(p, q) {
188+
return (p.name || '').toLowerCase().includes(q) ||
189+
(p.handle || '').toLowerCase().includes(q) ||
190+
(p.latest_target || '').toLowerCase().includes(q) ||
191+
(p.latest_target_brief || '').toLowerCase().includes(q);
192+
}
193+
194+
// When the user searches, the warm program list often carries an empty/rate-limited
195+
// scope for the match (the background warmer can't enrich ~800 H1 programs without
196+
// hitting the API rate limit). Fetch the matched programs' scope on demand with
197+
// force=true so a stale/empty cached value can't keep showing "—".
198+
let scopeSearchDebounce;
199+
async function hydrateMatchingScope() {
200+
const q = (document.getElementById('programs-search')?.value || '').toLowerCase().trim();
201+
if (q.length < 2) return;
202+
const need = programsData.filter(p =>
203+
p.platform && p.handle && !p._scope_loading && !p._scope_fresh &&
204+
!(Number(p.scope_targets) > 0) && !p.latest_target && matchesProgram(p, q)
205+
).slice(0, 25);
206+
if (!need.length) return;
207+
const mySeq = loadSeq;
208+
need.forEach(p => { p._scope_loading = true; });
209+
renderPrograms();
210+
try {
211+
const res = await window.apiPost('/api/scope/program-summaries', {
212+
programs: need.map(p => ({ platform: p.platform, handle: p.handle, url: p.url })),
213+
force: true,
214+
});
215+
if (mySeq !== loadSeq) return;
216+
const summaries = res.summaries || {};
217+
need.forEach(p => {
218+
const summary = summaries[programKey(p)] || summaries[`${p.platform}:${p.handle}`];
219+
if (summary) mergeScopeSummary(p, summary);
220+
p._scope_loading = false;
221+
p._scope_fresh = true; // attempted a live fetch; don't re-hit on every keystroke
222+
});
223+
} catch (e) {
224+
need.forEach(p => { p._scope_loading = false; });
225+
}
226+
renderPrograms();
227+
}
228+
229+
// Wired to the search box: filter immediately, then live-fetch scope for matches.
230+
function onSearch() {
231+
renderPrograms();
232+
clearTimeout(scopeSearchDebounce);
233+
scopeSearchDebounce = setTimeout(hydrateMatchingScope, 350);
234+
}
235+
187236
async function loadPrograms(force = false) {
188237
const seq = ++loadSeq;
189238
const platform = document.getElementById('programs-platform-filter')?.value || 'all';
@@ -230,6 +279,8 @@
230279
// the fresh data once it has likely finished.
231280
function refreshNow() {
232281
if (window.showToast) window.showToast('info', 'Refreshing', 'Rebuilding the program cache in the background…');
282+
// Allow the on-search live scope fetch to re-attempt after a manual refresh.
283+
programsData.forEach(p => { p._scope_fresh = false; });
233284
loadPrograms(true);
234285
setTimeout(() => { if (window.state && window.state.view === 'programs') loadPrograms(false); }, 30000);
235286
}
@@ -354,5 +405,5 @@
354405
}
355406
}
356407

357-
window.ProgramsPage = { loadPrograms, renderPrograms, setSort, refreshNow };
408+
window.ProgramsPage = { loadPrograms, renderPrograms, onSearch, setSort, refreshNow };
358409
})();

0 commit comments

Comments
 (0)