Safeguard
Application Security

A Scanner's Scope Guard Belongs in Code, Not in a Config File

The difference between a security test and an unauthorised attack is permission on the target. If that boundary is a setting, then a typo, a redirect or a merged config is all it takes to cross it.

Safeguard Research Team
Security Research
4 min read

An active dynamic scan sends malformed input, injection payloads, and traffic volumes no user would generate. Against a system you are authorised to test, that is security testing. Against one you are not, the description in most jurisdictions is unauthorised access, and the fact that your intentions were good is a mitigation rather than a defence.

The entire distinction rests on one property: is this host in scope? Which makes where that check lives an unusually load-bearing engineering decision.

Config is not a boundary

The common design puts an allow-list in the scan configuration and checks it when the scan starts. It fails in ordinary, non-adversarial ways.

Redirects. The scanner requests an in-scope URL. The server returns a 302 to a different host. The HTTP client follows redirects by default, and the request that lands on the out-of-scope host was never checked against the allow-list, because the check happened when the scan started and this URL did not exist then.

Discovered links. A crawler that finds <a href="https://partner.example.net/api"> and queues it has queued an out-of-scope target. Whether it gets scanned depends on where the filter sits — before the queue, or nowhere.

Wildcards. *.example.com in an allow-list looks precise. Whether it matches evil.example.com.attacker.net depends entirely on the implementation, and a suffix match written in a hurry says yes.

Config merging. Defaults, a project file, environment variables, CLI flags. Any layer can widen the scope, and the layer that did is discoverable only by reading four sources in precedence order.

Each of these is a bug in normal software. In a scanner, each one sends attack traffic to somebody who did not consent.

Make it an invariant

Enforce scope at the single point every request passes through, on every request, with no path around it:

func (c *ScopedClient) Do(req *http.Request) (*http.Response, error) {
    if !c.scope.Allows(req.URL.Hostname()) {
        return nil, ErrOutOfScope
    }
    // ...
}

Then make the surrounding decisions match:

  • Do not follow redirects automatically. Return the 302, check the target against the scope, and only then re-issue. This is the single highest-value change.
  • Filter at enqueue and at dequeue. Cheap, and it means a queue populated before a scope change cannot leak.
  • Resolve and re-check. DNS rebinding is real: a hostname that resolved in-scope at check time can resolve elsewhere at request time. Check the resolved address too, and reject private and loopback ranges unless explicitly permitted.
  • Match hosts exactly, or with an explicit subdomain rule. Never strings.HasSuffix.

The property to aim for is that no code path can send a request without passing the check — not "every call site remembers to call the check". Those are different guarantees, and only the first survives a new contributor.

Verify ownership before any of this matters

Scope answers which hosts may be scanned. It does not answer may we scan them at all.

Target ownership verification — a DNS TXT record, a file at a well-known path, a platform-level confirmation — should be a precondition the engine enforces, not a checkbox in a UI. The scan function should refuse a target that is not marked verified, and the verification status should come from a store the person requesting the scan cannot edit.

The reason for that last clause: a self-service scanner where the user asserts ownership is a self-service attack tool with a compliance disclaimer. The gate has to be somewhere the requester does not control.

Worth carrying the distinction into the code so it survives refactoring: the component that performs the scan consumes a pre-verified status, and something else entirely is responsible for establishing it. A scanner that verifies its own targets has one bug between it and being a weapon.

The blast radius when it goes wrong

A scope failure is not a normal defect.

Third-party impact. Injection payloads and traffic floods against someone else's production system, from your IP range, with your user agent.

Internal pivot. A scanner running inside a network that follows a link to 169.254.169.254 or an internal admin host is doing SSRF, except you built it deliberately and gave it credentials.

Legal exposure. Computer misuse legislation generally turns on authorisation, not intent. "Our config was wrong" describes how it happened, not whether it was lawful.

Reputational. The company selling security testing that attacked a bystander is a story that outlives the fix.

Given that, the engineering standard is higher than for a normal feature: the check is an invariant, its tests are adversarial — redirect chains, unicode host confusables, suffix-match near misses, rebinding — and the default posture on anything ambiguous is refuse.

A scanner that occasionally refuses something it could have scanned is an inconvenience. A scanner that occasionally scans something it should have refused is an incident.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.