From 510efff38b31be77a68d52ae9545611ec373e1ac Mon Sep 17 00:00:00 2001 From: Joshua MARTINELLE Date: Fri, 23 Oct 2026 19:11:52 +0200 Subject: [PATCH] feat(serve): accept an explicit target list in POST /run A request may now carry `targets`, the CLI's --targets: the run scans exactly those hosts, `domain` becomes an optional label, and the report says `run.input: targets` with no source queried. The list goes through enumerate.LoadTargets, the loader the CLI uses, before the run takes the instance: a host with a scheme, path or port, or an empty list, is a 400 and nothing is scanned. Exclusions still add to the deployment's and apply to the list. A list configured on the deployment no longer runs in place of, or merged into, the request's. The body cap goes from 64 KiB to 1 MiB so a list fits; past it the request is refused, never truncated. Co-Authored-By: Claude Opus 5.5 --- docs/01-usage.md | 2 + docs/04-deployment.md | 25 +++++++-- docs/05-design.md | 4 ++ internal/serve/serve.go | 36 +++++++++++-- internal/serve/serve_test.go | 102 +++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 10 deletions(-) diff --git a/docs/01-usage.md b/docs/01-usage.md index 4c590a8..cd75f09 100644 --- a/docs/01-usage.md +++ b/docs/01-usage.md @@ -63,6 +63,8 @@ fastrecon --targets-url https://internal/hosts --targets-header "Authorization: the value), and a redirect that would carry them to another host or down to `http` is refused. - `run.input` is `targets`, `sources` is empty, and `stats.enumerated` counts the list. +- `fastrecon serve` takes the same list as `targets` in the request body — see + [Deployment](04-deployment.md#fastrecon-serve). Background: [Why a target list exists](05-design.md#why-a-target-list-exists). diff --git a/docs/04-deployment.md b/docs/04-deployment.md index 2abec05..3b379f3 100644 --- a/docs/04-deployment.md +++ b/docs/04-deployment.md @@ -166,11 +166,26 @@ curl -X POST localhost:8080/run \ -d '{"domain":"example.com","stages":"enum"}' ``` -`POST /run` takes a `domain` and, optionally, `exclude`, `stages`, `ports` and `timeout`; -anything omitted comes from the environment. A request's `exclude` is **added** to the -deployment's exclusions, never substituted, so a caller cannot widen the scope. The response -is the [report](03-output.md#report-shape). `GET /healthz` needs no token, for platform -probes. +`POST /run` takes a `domain` or a `targets` list and, optionally, `exclude`, `stages`, +`ports` and `timeout`; anything omitted comes from the environment. The response is the +[report](03-output.md#report-shape). `GET /healthz` needs no token, for platform probes. + +| Field | Meaning | +|---|---| +| `domain` | the root to enumerate; required without `targets`, an optional report label with it | +| `targets` | bare hosts to scan instead of enumerating — the CLI's [`--targets`](01-usage.md#scanning-a-known-list) | +| `exclude` | patterns **added** to the deployment's exclusions, never substituted, so a caller cannot widen the scope | +| `stages` | `enum`, `resolve`, `ports` or `full` | +| `ports` | port expression, as `--ports` | +| `timeout` | Go duration, as `--timeout` | + +```json +{"targets": ["api.example.com", "www.example.net"], "stages": "resolve", "timeout": "30m"} +``` + +A `targets` list goes through the CLI's loader: a host with a scheme, a path or a port, an +empty list, or a body over 1 MiB is a `400`, and nothing is scanned. Exclusions still apply +to it, and a list configured on the deployment never runs in its place. | Status | Meaning | |---|---| diff --git a/docs/05-design.md b/docs/05-design.md index 63d06bd..661bbd8 100644 --- a/docs/05-design.md +++ b/docs/05-design.md @@ -71,6 +71,10 @@ statistics (verified in the engine's source). Hence: - **Exclusions only add up**: a request's `exclude` extends the deployment's, so it cannot lift a scope guard. +What a request *may* set is the target — a `domain` or a `targets` list — and how far to +take it. A list goes through the CLI's loader before the run takes the instance, so a bad +entry is refused whole rather than scanned in part. + Rejected: forking the engine to keep keys per run — forty-seven source integrations to maintain, for what a mutex solves. diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 0f37751..a6a3a9a 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -21,15 +21,19 @@ import ( "github.com/JoshuaMart/FastRecon/internal/app" "github.com/JoshuaMart/FastRecon/internal/config" + "github.com/JoshuaMart/FastRecon/internal/enumerate" "github.com/JoshuaMart/FastRecon/internal/report" "github.com/JoshuaMart/FastRecon/internal/secrets" "github.com/JoshuaMart/FastRecon/internal/stage" ) const ( - // maxBodyBytes bounds a request body: a handful of fields, so anything - // larger is a mistake or an attempt to exhaust the instance. - maxBodyBytes = 64 << 10 + // maxBodyBytes bounds a request body: a handful of fields and at most a + // target list, so anything larger is a mistake or an attempt to exhaust + // the instance. It is a refusal, not a truncation: a list cut short would + // turn hosts that were never queried into hosts that did not answer. A + // list this size is far past what fits in one function's timeout. + maxBodyBytes = 1 << 20 // busyRetryAfter is what a caller is told to wait when a run holds the // instance. busyRetryAfter = 30 * time.Second @@ -125,8 +129,12 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) error { // engine holds credentials in process-global state, and a caller-supplied // destination would turn the function into a request forwarder onto its own // network. +// +// Targets is the CLI's --targets: the run scans exactly these hosts instead of +// enumerating, and Domain becomes an optional label for the report. type runRequest struct { Domain string `json:"domain"` + Targets []string `json:"targets,omitempty"` Exclude []string `json:"exclude,omitempty"` Stages string `json:"stages,omitempty"` Ports string `json:"ports,omitempty"` @@ -155,7 +163,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { return } - runCfg, err := s.runConfig(req) + runCfg, err := s.runConfig(r.Context(), req) if err != nil { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return @@ -234,10 +242,28 @@ func decodeRequest(w http.ResponseWriter, r *http.Request) (runRequest, error) { // runConfig overlays the request onto the process configuration and validates // the result with the same rules the command line uses. -func (s *Server) runConfig(req runRequest) (*config.Config, error) { +func (s *Server) runConfig(ctx context.Context, req runRequest) (*config.Config, error) { cfg := s.app.Config().Clone() cfg.Domain = req.Domain + // The request says what to scan, so a list configured on the deployment + // does not run in its place or merge into it. + cfg.Targets, cfg.TargetsFile, cfg.TargetsURL, cfg.TargetsHeader = nil, "", "", nil + // Present but empty is refused rather than read as "no list": it is a + // caller whose list came out empty, not one asking to enumerate. + if req.Targets != nil { + // Checked here, with the loader the command line uses, so a bad list + // is a 400 before the run takes the instance. + hosts, err := enumerate.LoadTargets(ctx, enumerate.TargetOptions{ + Inline: req.Targets, + Redactor: s.app.Redactor(), + Logger: s.log, + }) + if err != nil { + return nil, err + } + cfg.Targets = hosts + } // Added to the deployment's exclusions, never in place of them: those // include the exclude file, and a request that could drop them could // send the run onto hosts the deployment declared out of scope. diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index bf21050..0c85d03 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -248,6 +248,108 @@ func TestOversizedBodyIsRejected(t *testing.T) { } } +// A target list runs through the real stage wiring: the same loader and +// enumerator the command line uses, with no source queried. +func TestTargetsReplaceEnumeration(t *testing.T) { + cfg := baseConfig(t) + cfg.Exclude = []string{"admin.example.com"} + a, err := app.New(cfg, discardLogger()) + if err != nil { + t.Fatalf("app.New: %v", err) + } + s, err := New(a, &config.Config{APIToken: "secret"}, discardLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + + rec := post(t, s, "secret", `{"targets":["WWW.example.com","api.example.net.","admin.example.com","old.example.com"],"exclude":["old.example.com"],"stages":"enum"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body %s", rec.Code, rec.Body) + } + var got report.Report + if err := json.Unmarshal(bytes.TrimSpace(rec.Body.Bytes()), &got); err != nil { + t.Fatalf("response is not a report: %v", err) + } + if got.Run.Input != report.InputTargets { + t.Errorf("input = %q, want %q", got.Run.Input, report.InputTargets) + } + if len(got.Sources) != 0 { + t.Errorf("sources = %v, want none queried", got.Sources) + } + if got.Run.Domain != "" { + t.Errorf("domain = %q, want no label when none was given", got.Run.Domain) + } + if got.Stats.Enumerated != 4 || got.Stats.Excluded != 2 { + t.Errorf("stats = %+v, want 4 supplied and 2 excluded", got.Stats) + } + var names []string + for _, h := range got.Hosts { + names = append(names, h.Host) + } + // The deployment's exclusion and the request's both apply to the list. + if !slices.Equal(names, []string{"api.example.net", "www.example.com"}) { + t.Errorf("hosts = %v, want the list normalized and both exclusions applied", names) + } +} + +func TestTargetsOverlayOntoTheProcessConfiguration(t *testing.T) { + runner := &fakeRunner{cfg: baseConfig(t)} + runner.cfg.TargetsFile = "/deployment/list.txt" + s := newTestServer(t, runner) + + rec := post(t, s, "secret", `{"domain":"Example.COM","targets":["b.example.com","a.example.com"],"stages":"resolve"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body %s", rec.Code, rec.Body) + } + got := runner.lastCfg + if !slices.Equal(got.Targets, []string{"a.example.com", "b.example.com"}) || got.TargetsFile != "" { + t.Errorf("targets = %v / %q, want only the request's list", got.Targets, got.TargetsFile) + } + if got.Domain != "example.com" { + t.Errorf("domain = %q, want the label normalized", got.Domain) + } + if len(runner.cfg.Targets) != 0 { + t.Errorf("the process configuration was mutated: %v", runner.cfg.Targets) + } +} + +// A list is scanned whole or not at all: a host dropped for its formatting is +// a host never queried, and nothing downstream would say so. +func TestInvalidTargets(t *testing.T) { + runner := &fakeRunner{} + s := newTestServer(t, runner) + + cases := map[string]string{ + "empty list": `{"targets":[]}`, + "scheme": `{"targets":["a.example.com","https://b.example.com"]}`, + "path": `{"targets":["a.example.com/login"]}`, + "port": `{"targets":["a.example.com:8443"]}`, + "single label": `{"targets":["localhost"]}`, + "bad domain label": `{"domain":"https://example.com","targets":["a.example.com"]}`, + "not a list": `{"targets":"a.example.com"}`, + } + for name, body := range cases { + rec := post(t, s, "secret", body) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400 (body %s)", name, rec.Code, rec.Body) + } + } + + var b strings.Builder + b.WriteString(`{"targets":["a.example.com"`) + for i := 0; b.Len() <= maxBodyBytes; i++ { + fmt.Fprintf(&b, `,"host-%d.example.com"`, i) + } + b.WriteString(`]}`) + if rec := post(t, s, "secret", b.String()); rec.Code != http.StatusBadRequest { + t.Errorf("oversized list: status = %d, want 400", rec.Code) + } + + if runner.calls != 0 { + t.Errorf("an invalid list reached the pipeline %d time(s)", runner.calls) + } +} + // Refused rather than queued, so a request does not spend its deadline waiting. func TestSecondConcurrentRunIsRefused(t *testing.T) { runner := &fakeRunner{block: make(chan struct{})}