Skip to content

Feat: Add session-aware HTTP client to Config - #547

Open
Chocapikk wants to merge 12 commits into
vulncheck-oss:mainfrom
Chocapikk:http-session-client
Open

Chocapikk wants to merge 12 commits into
vulncheck-oss:mainfrom
Chocapikk:http-session-client

Conversation

@Chocapikk

@Chocapikk Chocapikk commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a session-aware HTTP client directly in the protocol package that reduces boilerplate for exploits dealing with authentication, cookies, and multiple sequential requests, and folds the existing HTTPSendAndRecv* helpers onto the same engine so there is a single HTTP API surface.

protocol.NewGeneric(conf.GenerateURL) returns a persistent client that manages cookies automatically, injects auth headers when SetBasicAuth() is used, and resolves paths via the provided generateURL function.

The client is fully decoupled from Config - it takes a generateURL func(string) string instead of a *Config reference, so it's reusable without pulling in the config layer.

Real-world example

Here's a before/after from a real exploit (openDCIM SQLi-to-RCE chain, CVE-2026-28515):

Before - manual auth headers and request boilerplate:

func authHeaders(conf *config.Config) map[string]string {
    user := conf.GetStringFlag("username")
    pass := conf.GetStringFlag("password")
    if user == "" && pass == "" {
        return map[string]string{}
    }
    return map[string]string{"Authorization": protocol.BasicAuth(user, pass)}
}

func postForm(conf *config.Config, form map[string]string) bool {
    body := protocol.CreateRequestParamsEncoded(form)
    client, req, ok := protocol.CreateRequest("POST", conf.GenerateURL(installPath), body, false)
    if !ok {
        return false
    }
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    protocol.SetRequestHeaders(req, authHeaders(conf))
    resp, _, ok := protocol.DoRequest(client, req)
    if !ok {
        return false
    }
    return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
}

After - same exploit chain with the new protocol.Client:

h := protocol.NewGeneric(conf.GenerateURL)
h.SetBasicAuth(user, pass)

func injectSQL(h *protocol.Client, field, sql string) bool {
    resp, _, ok := h.PostFormEncoded(installPath, buildForm(field, sqliPrefix+sql+sqliSuffix), protocol.WithoutRedirect())
    if !ok || resp == nil {
        return false
    }
    return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
}

What's included

  • Cookie jar by default - cookies persist across requests automatically
  • Auth helpers - SetBasicAuth() / SetHeader() credentials included in every request
  • Functional options - WithoutCookies(), WithoutRedirect(), WithTimeout(), WithHeaders(), WithContentType(), FireAndForget()
  • Content-Type helpers - PostJSON, PostForm, PostFormEncoded, PostMultipart
  • Base path prefix - SetBasePath("/api/v1") to avoid repeating common prefixes
  • Fire and forget - for payloads where the server never responds
  • Custom client support - NewCustom() for when you need a non-default http.Client

Single HTTP API

To avoid having two parallel HTTP APIs, the legacy HTTPSendAndRecv* helpers and HTTPGetCache have been rewritten as thin wrappers that delegate to the new Client. Each wrapper still takes the same arguments, returns the same (*http.Response, string, bool) tuple, and behaves the same way externally — only the implementation has been collapsed onto the new engine.

For example:

func HTTPSendAndRecvWithHeaders(verb, url, payload string, headers map[string]string) (*http.Response, string, bool) {
    return newLegacyClient().Do(verb, url, payload, WithHeaders(headers))
}

This keeps every existing exploit working untouched while removing the duplicated request-building code.

Compatibility

Public signatures of HTTPSendAndRecv, HTTPSendAndRecvNoRedirect, HTTPSendAndRecvURLEncoded, HTTPSendAndRecvURLEncodedParams, HTTPSendAndRecvURLEncodedAndHeaders, HTTPSendAndRecvURLEncodedParamsAndHeaders, HTTPSendAndRecvWithHeaders, HTTPSendAndRecvWithHeadersNoRedirect, and HTTPGetCache are preserved. Behavior is preserved (each wrapper builds a one-shot Client so the old stateless semantics are kept). cacheResponse is private and was tweaked to take a URL string rather than a *http.Request, since the wrapper no longer owns the request directly.

CreateRequest, DoRequest, SetRequestHeaders, BasicAuth, CreateRequestParams, CreateRequestParamsEncoded, ParseCookies, CookieString, GetSetCookieValue, MultipartCreateForm, MultipartAddField, MultipartAddPart, MultipartAddFile, and DoRawHTTPRequest are unchanged and still public.

Test plan

  • go build ./...
  • go vet ./...
  • golangci-lint run ./... — no new findings introduced by this PR
  • go test ./protocol/ -v — 21 tests covering cookie persistence, auth injection, header merging, timeout, fire-and-forget, multipart upload, base path, and functional options
  • go test ./... — all project tests pass

@terrorbyte terrorbyte self-assigned this Mar 2, 2026
@terrorbyte terrorbyte added enhancement New feature or request proposal go Pull requests that update go code labels Mar 2, 2026
@terrorbyte

Copy link
Copy Markdown
Contributor

First off, thanks for the contributions and at first glance these all look really excellent! I'll get the team on reviewing them all and should get back to you with reviews pretty quick, with maybe the exception of this one as it's something we've long talked about on the VulnCheck side and there might be a some restructuring around the HTTP handling that is necessary (as I see you have caught on). So this one might have a bit more back and forth as we figure out what the best API strategy is :)

Thanks again for contributing!

@Chocapikk

Copy link
Copy Markdown
Contributor Author

First off, thanks for the contributions and at first glance these all look really excellent! I'll get the team on reviewing them all and should get back to you with reviews pretty quick, with maybe the exception of this one as it's something we've long talked about on the VulnCheck side and there might be a some restructuring around the HTTP handling that is necessary (as I see you have caught on). So this one might have a bit more back and forth as we figure out what the best API strategy is :)

Thanks again for contributing!

Thanks! Totally understand, this touches core architecture and there's a real choice between patching on top or rethinking the HTTP layer. Happy to adapt this PR to whatever direction the team decides, or hold off until the new design is clearer. Just let me know what's most useful.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new session-aware config.HTTPClient that hangs off Config to simplify exploit HTTP flows (persistent cookies, optional auth injection, URL generation, and convenience helpers), along with a comprehensive config package test suite for the new client.

Changes:

  • Introduce Config.HTTP() which lazily constructs a persistent HTTP client with cookie-jar support, redirect control, per-request functional options, and helper methods (JSON/form/multipart).
  • Add Config.AddHTTPAuth() and new Config fields to support opt-in HTTP Basic Auth header injection.
  • Add config/http_test.go covering cookie persistence, auth injection, headers, timeouts, redirect behavior, multipart, base path, and fire-and-forget behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
config/http.go Implements the new session-aware HTTP client, request options, and helper methods.
config/http_test.go Adds tests for the new HTTP client behaviors and functional options.
config/config.go Extends Config with optional HTTP Basic Auth support and the lazy HTTPClient handle.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread config/http.go Outdated
Comment on lines +128 to +130
// Refresh auth header each time in case credentials changed.
if conf.httpAuthEnabled && (conf.Username != "" || conf.Password != "") {
conf.httpClient.headers["Authorization"] = protocol.BasicAuth(conf.Username, conf.Password)

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conf.HTTP() refreshes the Authorization header when creds are present, but never clears it when credentials are later emptied (or when auth is disabled). This can leave a stale Authorization header being sent unexpectedly. Consider explicitly deleting the header when httpAuthEnabled is false or when both username/password are empty.

Suggested change
// Refresh auth header each time in case credentials changed.
if conf.httpAuthEnabled && (conf.Username != "" || conf.Password != "") {
conf.httpClient.headers["Authorization"] = protocol.BasicAuth(conf.Username, conf.Password)
// Refresh auth header each time in case credentials changed, and clear it when disabled.
if conf.httpAuthEnabled && (conf.Username != "" || conf.Password != "") {
conf.httpClient.headers["Authorization"] = protocol.BasicAuth(conf.Username, conf.Password)
} else {
delete(conf.httpClient.headers, "Authorization")

Copilot uses AI. Check for mistakes.
Comment thread protocol/client.go
Comment on lines +250 to +257
if ro.fireAndForget {
resp, _ := client.Do(req)
if resp != nil {
resp.Body.Close()
}

return nil, "", true
}

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In FireAndForget mode, doRequest ignores the error from client.Do(req) and always returns ok=true. This makes it impossible for callers to distinguish a successfully-dispatched request from cases like connection/DNS failures (and also drops any helpful logging). Either propagate the error (return ok=false) or document/rename the option to make it clear errors are intentionally suppressed, and consider logging the error at least at debug level.

Copilot uses AI. Check for mistakes.
Comment thread config/http.go Outdated
return path
}

return h.basePath + path

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvePath concatenates basePath + path directly, which can produce malformed paths when callers pass a path without a leading slash (e.g. basePath "/api" + "users" => "/apiusers"). Using protocol.BuildURI(h.basePath, path) (or otherwise normalizing slashes) would make SetBasePath more robust and consistent with existing URL-building helpers.

Suggested change
return h.basePath + path
return protocol.BuildURI(h.basePath, path)

Copilot uses AI. Check for mistakes.
Comment thread protocol/client_test.go
Comment on lines +276 to +284
func TestWithHeaders(t *testing.T) {
var received map[string]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received = map[string]string{
"X-Persistent": r.Header.Get("X-Persistent"),
"X-Extra": r.Header.Get("X-Extra"),
}
w.WriteHeader(http.StatusOK)
}))

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several tests share variables with the httptest handler goroutine (e.g. received here) without synchronization. This is a data race under go test -race and can be avoided by sending observed values back over a channel, using sync/atomic, or guarding with a mutex.

Copilot uses AI. Check for mistakes.
Comment thread config/http_test.go Outdated
Comment on lines +503 to +506
err := r.ParseMultipartForm(10 << 20)
if err != nil {
t.Fatalf("ParseMultipartForm failed: %s", err)
}

Copilot AI Mar 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling t.Fatalf (or t.Fatal) from inside the httptest server handler goroutine can lead to confusing failures and, in some cases, hangs/timeouts if the handler exits before writing a response. Prefer capturing the error in the handler (e.g., send it over a channel) and failing the test from the main test goroutine.

Copilot uses AI. Check for mistakes.

@spac3yspace spac3yspace left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Chocapikk, thanks for you PR, this is really great! I like these additions so far, and the only substantial change I'd request be made is decoupling the new httpclient from the existing Config struct. Not every exploit will need an associated http client, so I think separating this into its own package would work better. Maybe protocol/httpclient?

Comment thread config/http.go Outdated
// HTTP returns the lazily-initialized HTTPClient for this Config. The
// client maintains a cookie jar across requests and auto-injects auth
// headers when AddHTTPAuth was called with credentials set.
func (conf *Config) HTTP() *HTTPClient {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have two initializers? I think this would work great for creating a generic client, but It'd be really nice if we could also have one users can customize for better control of client & transport options. I was thinking something like ClientGeneric() and ClientCustom().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! I've addressed all the feedback.

The HTTP client is now fully decoupled from Config into its own protocol/httpclient package. It takes a generateURL function instead of a *Config reference, so it's reusable without pulling in the config layer. Constructors, options, cookie management, and all HTTP methods are self-contained there.

Let me know if anything else needs adjusting

@Chocapikk
Chocapikk requested a review from spac3yspace March 11, 2026 18:07

@spac3yspace spac3yspace left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further comments from me. These changes are great, thanks!

Chocapikk added 10 commits May 5, 2026 18:30
Adds HTTPClient, a context-aware HTTP wrapper on Config that manages
cookies automatically, injects auth headers, and supports functional
options (WithoutCookies, WithoutRedirect, WithTimeout, FireAndForget,
WithHeaders). Includes PostJSON, PostForm, PostMultipart, SetBasePath,
and ClearCookies. Replaces AuthHeaders() with auto-injection via
conf.HTTP().
Remove private do() method by routing content-type through requestOptions.
Add newTestHTTPClient() and cookieHandler() test helpers to eliminate
repeated 3-line setup and duplicate cookie handler boilerplate.
…tion

- Clear Authorization header when auth is disabled or credentials emptied
- Log FireAndForget errors at debug level instead of silently discarding
- Normalize basePath/path join to prevent malformed URLs
- Fix data races in tests by using channels instead of shared variables
- Replace t.Fatalf in httptest handler goroutines with channel-based errors
- Add tests for auth header cleanup and basePath without leading slash
- Fix lint: errcheck, goconst, unused params
Move session-aware HTTP client from config/ to its own package under
protocol/httpclient/. The Client struct now accepts a generateURL
function instead of holding a *Config reference, making it reusable
without coupling to the config layer.
- Move httpclient package into protocol (single API surface)
- Rewrite HTTPSendAndRecv* and HTTPGetCache as thin wrappers over Client
- Existing public signatures and behavior preserved (back-compat)
- Expose WithContentType option for verb-agnostic Content-Type setting
- cacheResponse takes URL string instead of *http.Request
@Chocapikk
Chocapikk force-pushed the http-session-client branch from c564758 to 7cb1144 Compare May 5, 2026 16:31
Chocapikk added 2 commits May 5, 2026 18:37
Each wrapper now points users at the Client API for new code while
keeping the existing signatures and behavior intact. No runtime change.

Reasons:
- Name-encoded option combinations (URLEncodedParamsAndHeaders, etc.)
  do not scale; functional options on Client compose cleanly.
- Stateless helpers cannot persist a cookie jar across requests, which
  is a structural limitation for any login -> action exploit chain.
  Client carries a cookie jar by default.
@j-baines

j-baines commented May 8, 2026

Copy link
Copy Markdown
Contributor

I think flagging the old functions as deprecated goes too far. It would break out internal linting for one, but I think it would also require a major version bump.

@vlobstein-vc

Copy link
Copy Markdown
Collaborator

I think flagging the old functions as deprecated goes too far. It would break out internal linting for one, but I think it would also require a major version bump.

Already reverted in 3e970c0, no Deprecated: tags left in the diff. Two ways to land the rest and both commits are on the branch:

  • Keep 7cb1144 : old HTTPSendAndRecv* helpers forward through the new Client engine. Same public API, single HTTP code path.
  • Drop 7cb1144 : old helpers keep their original bodies untouched. New Client lives alongside.

Which one do you want?

@Chocapikk
Chocapikk requested a review from spac3yspace June 28, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request go Pull requests that update go code proposal

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants