mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(providers): keep key-carrying provider requests on the configured origin
Provider clients took netclient.NewHTTPClient, so a redirect from the configured address was followed by Go's default policy and the key went with it. Add redirectguard.StayOnOrigin, build the probe, provider, System One, balance and doctor clients through endpointclient.New, and guard the default clients of the model listing, balance and System One calls. A refused redirect is a permanent error that names the target host and reaches the user as redirect_refused instead of a network fault retried ten times.
This commit is contained in:
1 parent
0807e52c2d
commit
11aa4014d0
34 files changed
+598
-21
No files matched your search
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
|
||||
- sensitive: internal/ext/pluginpkg/**
|
||||
- sensitive: internal/base/netclient/**
|
||||
- sensitive: internal/safety/redirectguard/**
|
||||
- sensitive: internal/safety/endpointclient/**
|
||||
- sensitive: internal/safety/egress/**
|
||||
- sensitive: internal/platform/browser/**
|
||||
- sensitive: internal/platform/computer/**
|
||||
|
||||
@@ -528,6 +528,7 @@ export const EN: Record<string, string> = {
|
||||
"凭据未通过,尚未确认": "Credentials were rejected; availability is still unknown",
|
||||
"遇到限流,尚未确认": "Rate limited; availability is still unknown",
|
||||
"网络异常,尚未确认": "Network error; availability is still unknown",
|
||||
"地址跳转到了另一个主机,已拒绝,尚未确认": "The address redirected to another host and was refused; availability is still unknown",
|
||||
"验证超时,尚未确认": "Verification timed out; availability is still unknown",
|
||||
"本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次": "The local service did not answer in time, so this is unconfirmed. A model that was just started may still be loading; verify again shortly.",
|
||||
"请求被拒绝,尚未确认": "Request rejected; availability is still unknown",
|
||||
|
||||
@@ -256,6 +256,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"The provider errored (HTTP {status}) — nothing you typed is wrong; try again shortly",
|
||||
"该地址在限定时间内没有响应。请检查网络或代理,或稍后重试":
|
||||
"That address did not answer in time. Check the network or proxy, or try again shortly",
|
||||
"该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}":
|
||||
"That address redirected to {target} and was refused so the key is not sent elsewhere. If you trust {target}, change the address to {target}",
|
||||
"检查失败,没有具体原因": "The check failed and gave no reason",
|
||||
"无法连接该地址。请检查网络是否通畅,以及地址是否有误":
|
||||
"Cannot reach that address. Check the network, or whether the address has a typo",
|
||||
|
||||
@@ -222,6 +222,7 @@ const SAID: Record<string, string> = {
|
||||
"provider.probe.upstream_error": "服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试",
|
||||
"provider.probe.timeout": "该地址在限定时间内没有响应。请检查网络或代理,或稍后重试",
|
||||
"provider.probe.unreachable": "无法连接该地址。请检查网络是否通畅,以及地址是否有误",
|
||||
"provider.probe.redirect_refused": "该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}",
|
||||
"provider.probe.failed": "检查失败,没有具体原因",
|
||||
"provider.probe.decision_path_not_found": "地址可以连通,但没有 System One 决策接口。请填写服务的基地址,不要带 /v1/systemone,Reasonix 会自己在后面追加",
|
||||
"provider.probe.decision_not_compatible": "该地址有响应,但返回的不是 System One 的决策结果。请确认这是决策协议服务的基地址",
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface ProviderCheck {
|
||||
ambiguous?: boolean;
|
||||
noProxy?: boolean;
|
||||
code?: string;
|
||||
// The host a refused redirect pointed at.
|
||||
target?: string;
|
||||
// Only the numbers the code's sentence needs: `status`, `count`.
|
||||
params?: Record<string, number>;
|
||||
httpStatus?: number;
|
||||
@@ -160,6 +162,8 @@ export type ProviderModelCheckReason =
|
||||
| "timeout"
|
||||
// Timed out against the machine's own address: the engine did not answer, not the network.
|
||||
| "local_timeout"
|
||||
// The address redirected to another host; the detail names it.
|
||||
| "redirect_refused"
|
||||
// The endpoint answered chat and refused a tools array. Established by
|
||||
// sending the same request again without one, not by reading the refusal.
|
||||
| "tools";
|
||||
|
||||
@@ -224,6 +224,7 @@ function checkReason(reason?: ProviderModelCheckReason): string {
|
||||
case "network": return "网络异常,尚未确认";
|
||||
case "timeout": return "验证超时,尚未确认";
|
||||
case "local_timeout": return "本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次";
|
||||
case "redirect_refused": return "地址跳转到了另一个主机,已拒绝,尚未确认";
|
||||
case "rejected": return "请求被拒绝,尚未确认";
|
||||
default: return "尚未确认";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkFailure } from "./provider_check";
|
||||
|
||||
describe("checkFailure", () => {
|
||||
it("names the host a refused redirect pointed at", () => {
|
||||
const said = checkFailure({ ok: false, code: "provider.probe.redirect_refused", target: "www.example.com" });
|
||||
expect(said).toContain("www.example.com");
|
||||
expect(said).not.toContain("{target}");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { say } from "../i18n/kernel";
|
||||
import type { ProviderCheck } from "../port/port";
|
||||
|
||||
export function checkFailure(check: ProviderCheck): string {
|
||||
const said = say({ code: check.code, params: check.params }) || t("检查失败,没有具体原因");
|
||||
const said = say({ code: check.code, params: { ...check.params, ...(check.target ? { target: check.target } : {}) } }) || t("检查失败,没有具体原因");
|
||||
const status = check.httpStatus ? ` · HTTP ${check.httpStatus}` : "";
|
||||
const words = check.detail ? ` · ${check.detail}` : "";
|
||||
return said + status + words;
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"reasonix/internal/platform/lsp"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
"reasonix/internal/safety/permission"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
"reasonix/internal/session/control"
|
||||
@@ -58,6 +59,7 @@ type builder struct {
|
||||
keep agent.KeepPolicy
|
||||
session sessionRuntime
|
||||
balanceClient *http.Client
|
||||
keyedClient *http.Client // carries provider keys; refuses cross-origin redirects
|
||||
execProv provider.Provider
|
||||
shell sandbox.Shell
|
||||
prompt promptAssembly
|
||||
@@ -227,6 +229,9 @@ func (b *builder) load() error {
|
||||
if b.balanceClient, err = netclient.NewHTTPClient(b.proxy, netclient.TransportOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.keyedClient, err = endpointclient.New(b.proxy, netclient.TransportOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.execProv, err = resolveProvider(b.providers.effective, cfg, b.proxy, provider.Selection{Ref: b.model.ref, Effort: opts.EffortOverride}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -316,7 +321,7 @@ func (b *builder) wireTools() error {
|
||||
// The full inventory registers for use_capability; the provider-visible surface narrows later.
|
||||
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
|
||||
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
|
||||
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.balanceClient)
|
||||
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.keyedClient)
|
||||
addAdvisor(t.reg, cfg, b.proxy, b.sink)
|
||||
b.addBestOf()
|
||||
if cfg.Agent.CodeMode {
|
||||
@@ -541,7 +546,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
|
||||
Memory: b.prompt.memory,
|
||||
// Read at Close time: freeze chains the extension runtime set onto it.
|
||||
Cleanup: func() { b.cleanup() },
|
||||
Balance: opts.BalanceStore.Cache(b.balanceClient, entry.BalanceURL, entry.APIKey()),
|
||||
Balance: opts.BalanceStore.Cache(b.keyedClient, entry.BalanceURL, entry.APIKey()),
|
||||
Feedback: control.FeedbackOptions{Service: feedbackService(b.roots.Home(), b.proxy), ProviderKind: feedbackProviderKind(entry), Surface: opts.FeedbackSurface},
|
||||
Jobs: b.session.jobs,
|
||||
TaskStore: opts.TaskStore,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
type systemOneCaller struct{ round atomic.Int32 }
|
||||
|
||||
func (p *systemOneCaller) Name() string { return "boot-keyed-client" }
|
||||
|
||||
func (p *systemOneCaller) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
if p.round.Add(1) == 1 {
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
|
||||
ID: "s1", Name: "system_one",
|
||||
Arguments: `{"state":"s","questions":{"q":{"type":"noul","instructions":"is it?"}}}`,
|
||||
}}
|
||||
} else {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// The System One call and the balance read both carry the provider key, so a
|
||||
// redirect off the configured address must not be followed by either.
|
||||
func TestEffectKeyCarryingBuildClientsRefuseACrossOriginRedirect(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Add(1) }))
|
||||
defer elsewhere.Close()
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer home.Close()
|
||||
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
t.Setenv("KEYED_EFFECT_KEY", "secret")
|
||||
caller := &systemOneCaller{}
|
||||
provider.Register("typesafe", func(provider.Config) (provider.Provider, error) { return caller, nil })
|
||||
provider.Register("boot-keyed-chat", func(provider.Config) (provider.Provider, error) { return caller, nil })
|
||||
userCfg := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(userCfg, []byte(`
|
||||
default_model = "chat/m"
|
||||
|
||||
[agent]
|
||||
decision_model = "decider/system-one"
|
||||
|
||||
[[providers]]
|
||||
name = "chat"
|
||||
kind = "boot-keyed-chat"
|
||||
models = ["m"]
|
||||
default = "m"
|
||||
api_key_env = "KEYED_EFFECT_KEY"
|
||||
balance_url = "`+home.URL+`/balance"
|
||||
|
||||
[[providers]]
|
||||
name = "decider"
|
||||
kind = "typesafe"
|
||||
balance_url = "`+home.URL+`/balance"
|
||||
base_url = "`+home.URL+`"
|
||||
models = ["system-one"]
|
||||
api_key_env = "KEYED_EFFECT_KEY"
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
if err := ctrl.Run(context.Background(), "ask system one"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
reading := ctrl.Balance(context.Background())
|
||||
if reading.Err == nil || !strings.Contains(reading.Err.Error(), "redirect refused") {
|
||||
t.Fatalf("balance reading = %+v, want a refused redirect", reading)
|
||||
}
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("the redirect target received %d request(s)", n)
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
|
||||
// permanentTransportErr reports a transport failure no backoff can fix: the
|
||||
// endpoint refused the connection, its name does not resolve, or its
|
||||
// certificate does not verify. These fail on the first attempt.
|
||||
// certificate does not verify, or the error itself says it is permanent. These fail on the first attempt.
|
||||
func permanentTransportErr(err error) bool {
|
||||
if connectionRefused(err) {
|
||||
var carried interface{ Permanent() bool }
|
||||
if connectionRefused(err) || (errors.As(err, &carried) && carried.Permanent()) {
|
||||
return true
|
||||
}
|
||||
var dns *net.DNSError
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
// 1.x failed a request to an endpoint that refuses connections at once;
|
||||
@@ -39,6 +42,28 @@ func TestSendWithRetryFailsFastWhenTheEndpointRefuses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithRetryDoesNotRetryARefusedRedirect(t *testing.T) {
|
||||
elsewhere := httptest.NewServer(http.NotFoundHandler())
|
||||
defer elsewhere.Close()
|
||||
calls := 0
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer home.Close()
|
||||
cl := &http.Client{CheckRedirect: redirectguard.StayOnOrigin()}
|
||||
_, err := SendWithRetry(WithRetryLimit(context.Background(), 2), cl, SendOptions{Provider: "p"}, func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodPost, home.URL, nil)
|
||||
})
|
||||
var left *redirectguard.OriginLeft
|
||||
if !errors.As(err, &left) || left.To == "" {
|
||||
t.Fatalf("err = %v, want it to name the host it was redirected to", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("a refused redirect was attempted %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentTransportErrReadsIdentity(t *testing.T) {
|
||||
refused := &net.OpError{Op: "dial", Net: "tcp", Err: os.NewSyscallError("connect", syscall.ECONNREFUSED)}
|
||||
reset := &net.OpError{Op: "read", Net: "tcp", Err: os.NewSyscallError("read", syscall.ECONNRESET)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"reasonix/internal/base/secrets"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
"reasonix/internal/safety/typesafe"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,9 @@ type providerCheck struct {
|
||||
NoProxy bool `json:"noProxy,omitempty"`
|
||||
// Code is why the check failed, as the dotted identity the add flow's
|
||||
// refusals use. Params carry only the numbers its sentence needs.
|
||||
Code string `json:"code,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
// Target is the host a refused redirect pointed at, for the sentence.
|
||||
Target string `json:"target,omitempty"`
|
||||
Params map[string]int `json:"params,omitempty"`
|
||||
// HTTPStatus and Detail are what the endpoint answered with, for the user
|
||||
// to read; neither is an input to Code.
|
||||
@@ -128,6 +131,10 @@ func probeFinding(err error, apiKey func() string) providerCheck {
|
||||
}
|
||||
_, code := probeReasonRefusal(probe.Reason)
|
||||
found := providerCheck{Code: code, HTTPStatus: probe.Status, Detail: endpointDetail(probe.Body, apiKey)}
|
||||
var left *redirectguard.OriginLeft
|
||||
if errors.As(err, &left) {
|
||||
found.Detail, found.Target = "", left.To
|
||||
}
|
||||
for name, value := range probe.Params {
|
||||
if n, ok := value.(int); ok {
|
||||
if found.Params == nil {
|
||||
@@ -295,7 +302,10 @@ func modelCheckDetail(err error, apiKey func() string) string {
|
||||
var auth *provider.AuthError
|
||||
var apiErr *provider.APIError
|
||||
var streamErr *provider.StreamPayloadError
|
||||
var left *redirectguard.OriginLeft
|
||||
switch {
|
||||
case errors.As(err, &left):
|
||||
text = left.Error()
|
||||
case errors.As(err, &typeSafeErr):
|
||||
text = typeSafeErr.Body
|
||||
case errors.As(err, &auth):
|
||||
@@ -455,6 +465,9 @@ func classifyProviderModelCheck(err error) (status, reason string, httpStatus in
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return "unknown", "timeout", 0
|
||||
}
|
||||
if errors.Is(err, redirectguard.ErrRefused) {
|
||||
return "unknown", "redirect_refused", 0
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) || provider.IsStreamInterrupted(err) {
|
||||
return "unknown", "network", 0
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/config"
|
||||
@@ -36,7 +37,7 @@ func probeDecision(ctx context.Context, cfg *config.Config, entry *config.Provid
|
||||
if entry.NoProxy && proxy.Mode != netclient.ModeCustom {
|
||||
proxy = netclient.ProxySpec{Mode: netclient.ModeOff}
|
||||
}
|
||||
client, err := netclient.NewHTTPClient(proxy, netclient.TransportOptions{})
|
||||
client, err := endpointclient.New(proxy, netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
return errors.New("decision probe client: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every request that carries a provider key to a configured address must stay
|
||||
// on that address: a redirect elsewhere is refused before the key is resent.
|
||||
func TestProviderProbesRefuseACrossOriginRedirect(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, path, kind string
|
||||
status int
|
||||
}{
|
||||
{"single-model check", "/providers/check/model", "openai", http.StatusTemporaryRedirect},
|
||||
{"decision probe", "/providers/check/model", "typesafe", http.StatusPermanentRedirect},
|
||||
{"endpoint probe", "/providers/probe", "openai", http.StatusFound},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
reached.Add(1)
|
||||
}))
|
||||
defer elsewhere.Close()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL+r.URL.Path, tc.status)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
s := newProviderEditServer(t)
|
||||
s.AllowProviderEdit()
|
||||
srv := httptest.NewServer(operatorHandler(s))
|
||||
defer srv.Close()
|
||||
body := fmt.Sprintf(`{"name":"not-saved","model":"m","baseUrl":%q,"apiKey":"secret-key","kind":%q}`, upstream.URL+"/v1", tc.kind)
|
||||
if tc.path == "/providers/probe" {
|
||||
body = fmt.Sprintf(`{"baseUrl":%q,"apiKey":"secret-key"}`, upstream.URL+"/v1")
|
||||
}
|
||||
resp := postProvider(t, srv.URL, tc.path, body)
|
||||
defer resp.Body.Close()
|
||||
var got struct {
|
||||
Code string `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
Msg string `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := strings.TrimPrefix(elsewhere.URL, "http://")
|
||||
switch tc.path {
|
||||
case "/providers/probe":
|
||||
if got.Code != "provider.probe.redirect_refused" || !strings.Contains(got.Msg, host) {
|
||||
t.Fatalf("probe refusal = %+v, want redirect_refused naming %s", got, host)
|
||||
}
|
||||
default:
|
||||
if got.Reason != "redirect_refused" || !strings.Contains(got.Detail, host) {
|
||||
t.Fatalf("model check = %+v, want redirect_refused naming %s", got, host)
|
||||
}
|
||||
}
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("the redirect target received %d request(s); the key left the configured address", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
// hostGrants are the surfaces a host opens on behalf of its one local client.
|
||||
@@ -493,8 +495,8 @@ func probeClients() (proxied, direct *http.Client) {
|
||||
if cfg, err := config.Load(); err == nil && cfg != nil {
|
||||
spec = cfg.NetworkProxySpec()
|
||||
}
|
||||
proxied, _ = netclient.NewHTTPClient(spec, netclient.TransportOptions{})
|
||||
direct, _ = netclient.NewHTTPClient(netclient.ProxySpec{Mode: netclient.ModeOff}, netclient.TransportOptions{})
|
||||
proxied, _ = endpointclient.New(spec, netclient.TransportOptions{})
|
||||
direct, _ = endpointclient.New(netclient.ProxySpec{Mode: netclient.ModeOff}, netclient.TransportOptions{})
|
||||
return proxied, direct
|
||||
}
|
||||
|
||||
@@ -523,6 +525,7 @@ const (
|
||||
codeProbeNoChatModels = "provider.probe.no_chat_models"
|
||||
codeProbeUpstreamError = "provider.probe.upstream_error"
|
||||
codeProbeTimeout = "provider.probe.timeout"
|
||||
codeProbeRedirectRefused = "provider.probe.redirect_refused"
|
||||
codeProbeUnreachable = "provider.probe.unreachable"
|
||||
codeProbeNotCompatible = "provider.probe.not_compatible"
|
||||
codeProbeFailed = "provider.probe.failed"
|
||||
@@ -555,6 +558,8 @@ func probeReasonRefusal(reason catalog.ProbeReason) (status int, code string) {
|
||||
return http.StatusGatewayTimeout, codeProbeTimeout
|
||||
case catalog.ProbeUnreachable:
|
||||
return http.StatusBadGateway, codeProbeUnreachable
|
||||
case catalog.ProbeRedirectRefused:
|
||||
return http.StatusBadGateway, codeProbeRedirectRefused
|
||||
default:
|
||||
return http.StatusBadGateway, codeProbeNotCompatible
|
||||
}
|
||||
@@ -574,5 +579,10 @@ func writeProbeFailure(w http.ResponseWriter, err error, apiKey string) {
|
||||
if detail := endpointDetail(probe.Body, func() string { return apiKey }); detail != "" {
|
||||
message += ": " + detail
|
||||
}
|
||||
var left *redirectguard.OriginLeft
|
||||
if errors.As(err, &left) {
|
||||
message = left.Error()
|
||||
probe.Params = map[string]any{"from": left.From, "target": left.To}
|
||||
}
|
||||
refuse(w, status, code, message, probe.Params)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/model/openai"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
)
|
||||
|
||||
// defaultStreamIdleTimeout caps how long a started SSE stream may go silent before
|
||||
@@ -154,7 +155,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
|
||||
|
||||
func newHTTPClient(cfg provider.Config) (*http.Client, error) {
|
||||
spec, _ := cfg.Extra["proxy_spec"].(netclient.ProxySpec)
|
||||
return netclient.NewHTTPClient(spec, netclient.TransportOptions{ResponseHeaderTimeout: provider.IdleTimeoutFromExtra(cfg.Extra)})
|
||||
return endpointclient.New(spec, netclient.TransportOptions{ResponseHeaderTimeout: provider.IdleTimeoutFromExtra(cfg.Extra)})
|
||||
}
|
||||
|
||||
type client struct {
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
// A wallet read fails in ways the reader must tell apart: a rejected credential
|
||||
@@ -101,7 +103,7 @@ func positive(amount string) bool {
|
||||
|
||||
// httpClient bounds the balance query so a slow endpoint can't hang the status
|
||||
// line; the per-call ctx still cancels it on shutdown.
|
||||
var httpClient = &http.Client{Timeout: 12 * time.Second}
|
||||
var httpClient = &http.Client{Timeout: 12 * time.Second, CheckRedirect: redirectguard.StayOnOrigin()}
|
||||
|
||||
// Fetch queries url (a DeepSeek-style balance endpoint) with a Bearer apiKey and
|
||||
// returns the normalized balance. An empty url yields (nil, nil) — "not
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchDoesNotFollowARedirectWithTheKey(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Add(1) }))
|
||||
defer elsewhere.Close()
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer home.Close()
|
||||
if _, err := Fetch(context.Background(), home.URL, "secret"); err == nil {
|
||||
t.Fatal("a cross-origin redirect was followed")
|
||||
}
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("redirect target received %d request(s)", n)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"reasonix/internal/model/openai"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
// ProbeReason names why probing failed. Each one sends the user to a different
|
||||
@@ -27,6 +28,7 @@ const (
|
||||
ProbeTimeout ProbeReason = "timeout"
|
||||
ProbeUnreachable ProbeReason = "unreachable"
|
||||
ProbeNotCompatible ProbeReason = "not_compatible"
|
||||
ProbeRedirectRefused ProbeReason = "redirect_refused"
|
||||
)
|
||||
|
||||
// ProbeError carries that identity out. Params hold the facts a sentence needs
|
||||
@@ -56,6 +58,7 @@ func (e *ProbeError) Unwrap() error { return e.err }
|
||||
// listing that answered with no chat models settles the question, a 401 says
|
||||
// more than a 404, and "not compatible" is what is left when nothing spoke.
|
||||
var probeReasonRank = map[ProbeReason]int{
|
||||
ProbeRedirectRefused: -1,
|
||||
ProbeNoChatModels: 0,
|
||||
ProbePaymentRequired: 1,
|
||||
ProbeUnauthorized: 2,
|
||||
@@ -74,6 +77,9 @@ func classifyProbe(err error) *ProbeError {
|
||||
if errors.As(err, &known) {
|
||||
return known
|
||||
}
|
||||
if errors.Is(err, redirectguard.ErrRefused) {
|
||||
return &ProbeError{Reason: ProbeRedirectRefused, err: err}
|
||||
}
|
||||
status, ok := openai.ModelFetchStatus(err)
|
||||
if !ok {
|
||||
var netErr net.Error
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
type modelFetchStatusError struct {
|
||||
@@ -107,7 +108,7 @@ type ListedModel struct {
|
||||
func FetchModelListing(ctx context.Context, baseURL, apiKey string, opts FetchModelsOptions) ([]ListedModel, error) {
|
||||
cli := opts.Client
|
||||
if cli == nil {
|
||||
cli = &http.Client{Timeout: 10 * time.Second}
|
||||
cli = &http.Client{Timeout: 10 * time.Second, CheckRedirect: redirectguard.StayOnOrigin()}
|
||||
}
|
||||
url := strings.TrimRight(baseURL, "/")
|
||||
if !strings.HasSuffix(url, "/models") {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchModelListingWithoutAClientDoesNotFollowARedirectWithTheKey(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Add(1) }))
|
||||
defer elsewhere.Close()
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer home.Close()
|
||||
if _, err := FetchModelListing(context.Background(), home.URL+"/v1", "secret", FetchModelsOptions{}); err == nil {
|
||||
t.Fatal("a cross-origin redirect was followed")
|
||||
}
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("redirect target received %d request(s)", n)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
)
|
||||
|
||||
// defaultStreamIdleTimeout caps how long a started SSE stream may go without any
|
||||
@@ -264,7 +265,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
|
||||
|
||||
func newHTTPClient(cfg provider.Config) (*http.Client, error) {
|
||||
spec, _ := cfg.Extra["proxy_spec"].(netclient.ProxySpec)
|
||||
return netclient.NewHTTPClient(spec, netclient.TransportOptions{
|
||||
return endpointclient.New(spec, netclient.TransportOptions{
|
||||
DialTimeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/provider"
|
||||
provideropenai "reasonix/internal/model/openai"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -164,8 +166,8 @@ func New(cfg Config) provider.Provider {
|
||||
if vendor == "deepseek" {
|
||||
vision = vision && provideropenai.DeepSeekTakesImages(cfg.Model)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 300 * time.Second}
|
||||
if built, err := netclient.NewHTTPClient(cfg.Proxy, netclient.TransportOptions{
|
||||
httpClient := &http.Client{Timeout: 300 * time.Second, CheckRedirect: redirectguard.StayOnOrigin()}
|
||||
if built, err := endpointclient.New(cfg.Proxy, netclient.TransportOptions{
|
||||
DialTimeout: 30 * time.Second, KeepAlive: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: provider.IdleTimeoutFromExtra(cfg.Extra),
|
||||
}); err == nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package repair
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
type DiagnosticFinding struct {
|
||||
@@ -295,7 +298,7 @@ func checkDerivedJSON(report *DiagnosticReport) {
|
||||
}
|
||||
|
||||
func probeProviderNetwork(ctx context.Context, report *DiagnosticReport, cfg *config.Config, timeout time.Duration) {
|
||||
client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{DialTimeout: timeout, TLSHandshakeTimeout: timeout, ResponseHeaderTimeout: timeout})
|
||||
client, err := endpointclient.New(cfg.NetworkProxySpec(), netclient.TransportOptions{DialTimeout: timeout, TLSHandshakeTimeout: timeout, ResponseHeaderTimeout: timeout})
|
||||
if err != nil {
|
||||
report.add("error", "network.client_failed", "network", "Cannot build network client: "+err.Error(), "Correct proxy settings.")
|
||||
return
|
||||
@@ -324,7 +327,9 @@ func probeProviderNetwork(ctx context.Context, report *DiagnosticReport, cfg *co
|
||||
}
|
||||
}
|
||||
resp, callErr := client.Do(req)
|
||||
if callErr != nil {
|
||||
if errors.Is(callErr, redirectguard.ErrRefused) {
|
||||
report.add("warning", "network.redirect_refused", "provider:"+entry.Name, "Provider endpoint redirected to another host and was not followed.", "If you trust the new host, set the provider's base_url to it.")
|
||||
} else if callErr != nil {
|
||||
report.add("warning", "network.unreachable", "provider:"+entry.Name, "Provider endpoint could not be reached: "+redactNetworkError(callErr), "Check DNS, proxy, firewall, and provider availability.")
|
||||
} else {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestProbeProviderNetworkDoesNotFollowARedirectWithTheKey(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Add(1) }))
|
||||
defer elsewhere.Close()
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer home.Close()
|
||||
t.Setenv("DOCTOR_PROBE_KEY", "secret")
|
||||
cfg := &config.Config{Providers: []config.ProviderEntry{{Name: "p", Kind: "openai", BaseURL: home.URL + "/v1", APIKeyEnv: "DOCTOR_PROBE_KEY", Headers: map[string]string{"x-extra": "1"}}}}
|
||||
var report DiagnosticReport
|
||||
probeProviderNetwork(t.Context(), &report, cfg, 5*time.Second)
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("redirect target received %d request(s)", n)
|
||||
}
|
||||
for _, f := range report.Findings {
|
||||
if f.Code == "network.redirect_refused" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("findings = %+v, want network.redirect_refused", report.Findings)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package endpointclient builds the HTTP client for requests that carry a
|
||||
// credential to an address the user configured. A redirect that leaves that
|
||||
// origin is refused before the request, and the credential with it, is resent.
|
||||
package endpointclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
// New is netclient.NewHTTPClient with the redirect policy of redirectguard.StayOnOrigin.
|
||||
func New(spec netclient.ProxySpec, opts netclient.TransportOptions) (*http.Client, error) {
|
||||
c, err := netclient.NewHTTPClient(spec, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CheckRedirect = redirectguard.StayOnOrigin()
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package endpointclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
func TestNewRefusesARedirectOffTheConfiguredOrigin(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Add(1) }))
|
||||
defer elsewhere.Close()
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v2":
|
||||
case "/moved":
|
||||
http.Redirect(w, r, "/v2", http.StatusTemporaryRedirect)
|
||||
default:
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
}))
|
||||
defer home.Close()
|
||||
client, err := New(netclient.ProxySpec{Mode: netclient.ModeOff}, netclient.TransportOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := client.Post(home.URL, "application/json", strings.NewReader("{}")); !errors.Is(err, redirectguard.ErrRefused) {
|
||||
t.Fatalf("cross-origin redirect error = %v, want redirectguard.ErrRefused", err)
|
||||
}
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("redirect target received %d request(s)", n)
|
||||
}
|
||||
resp, err := client.Post(home.URL+"/moved", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("same-origin redirect refused: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -7,16 +7,32 @@
|
||||
package redirectguard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrRefused identifies a redirect that was not followed, so a caller can tell
|
||||
// one from a transport failure. The message names the rule that stopped it and
|
||||
// is for whoever reads the log.
|
||||
var ErrRefused = errors.New("redirect refused")
|
||||
var ErrRefused error = refused{}
|
||||
|
||||
// refused is a failure no retry can fix: the same request goes to the same place.
|
||||
type refused struct{}
|
||||
|
||||
func (refused) Error() string { return "redirect refused" }
|
||||
func (refused) Permanent() bool { return true }
|
||||
|
||||
// OriginLeft is the refusal of a redirect to another host. From and To are
|
||||
// host[:port] only, so the message is safe to show and carries no path or query.
|
||||
type OriginLeft struct{ From, To string }
|
||||
|
||||
func (e *OriginLeft) Error() string {
|
||||
return fmt.Sprintf("redirect refused: %s redirected to %s; if you trust %s, set the address to %s", e.From, e.To, e.To, e.To)
|
||||
}
|
||||
|
||||
func (e *OriginLeft) Unwrap() error { return ErrRefused }
|
||||
|
||||
// maxHops is Go's own default, restated because a CheckRedirect replaces that
|
||||
// default outright: without a limit here a chain never stops.
|
||||
@@ -52,6 +68,50 @@ func Follow(hosts ...string) func(*http.Request, []*http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
// StayOnOrigin returns a CheckRedirect for a request to an address the user
|
||||
// chose, which may be HTTP or carry a port. A redirect is followed only on the
|
||||
// host the chain started on (same port, or the default-port HTTP to HTTPS
|
||||
// upgrade), never downgrades HTTPS and carries no credentials.
|
||||
func StayOnOrigin() func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxHops {
|
||||
return fmt.Errorf("%w: stopped after %d hops", ErrRefused, maxHops)
|
||||
}
|
||||
if req == nil || req.URL == nil || len(via) == 0 || via[0].URL == nil {
|
||||
return fmt.Errorf("%w: no origin to stay on", ErrRefused)
|
||||
}
|
||||
origin, target := via[0].URL, req.URL
|
||||
if target.User != nil {
|
||||
return fmt.Errorf("%w: carries credentials: %q", ErrRefused, target.String())
|
||||
}
|
||||
if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(target.Scheme, "https") {
|
||||
return fmt.Errorf("%w: downgrades HTTPS: %q", ErrRefused, target.String())
|
||||
}
|
||||
if !sameOrigin(origin, target) {
|
||||
return &OriginLeft{From: origin.Host, To: target.Host}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sameOrigin(origin, target *url.URL) bool {
|
||||
if !strings.EqualFold(strings.TrimSuffix(origin.Hostname(), "."), strings.TrimSuffix(target.Hostname(), ".")) {
|
||||
return false
|
||||
}
|
||||
from, to := effectivePort(origin), effectivePort(target)
|
||||
return from == to || (from == "80" && to == "443" && !strings.EqualFold(origin.Scheme, "https"))
|
||||
}
|
||||
|
||||
func effectivePort(u *url.URL) string {
|
||||
if p := u.Port(); p != "" {
|
||||
return p
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "https") {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
// permitted compares the way DNS does: case-insensitively, and with the root
|
||||
// label a resolver accepts but a string comparison would not.
|
||||
func permitted(host string, hosts []string) bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package redirectguard
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -91,3 +92,77 @@ func TestFollowWithNoHostsTrustsNothing(t *testing.T) {
|
||||
t.Fatal("a guard with no trusted hosts followed a redirect anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStayOnOriginFollowsOnlyTheHostItStartedOn(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, from, to string
|
||||
hops int
|
||||
want bool
|
||||
}{
|
||||
{"same host and port", "http://localhost:8080/v1", "http://localhost:8080/v2", 1, true},
|
||||
{"path-only move on https", "https://api.example.com/v1", "https://api.example.com/v2", 1, true},
|
||||
{"http to https upgrade on default ports", "http://relay.example.com/v1", "https://relay.example.com/v1", 1, true},
|
||||
{"host case and root label", "https://API.example.com/v1", "https://api.example.com./v1", 1, true},
|
||||
{"another port on the same host", "http://127.0.0.1:1/v1", "http://127.0.0.1:2/v1", 1, false},
|
||||
{"another host", "https://api.example.com/v1", "https://evil.test/v1", 1, false},
|
||||
{"a sibling subdomain", "https://api.example.com/v1", "https://cdn.example.com/v1", 1, false},
|
||||
{"https downgraded to http", "https://api.example.com/v1", "http://api.example.com/v1", 1, false},
|
||||
{"credentials in the target", "https://api.example.com/v1", "https://u:[email protected]/v1", 1, false},
|
||||
{"too many hops", "https://api.example.com/v1", "https://api.example.com/v2", 10, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
first, err := http.NewRequest(http.MethodPost, tc.from, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := http.NewRequest(http.MethodPost, tc.to, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
via := make([]*http.Request, tc.hops)
|
||||
for i := range via {
|
||||
via[i] = first
|
||||
}
|
||||
err = StayOnOrigin()(next, via)
|
||||
if (err == nil) != tc.want {
|
||||
t.Fatalf("StayOnOrigin %s -> %s = %v, want follow=%v", tc.from, tc.to, err, tc.want)
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrRefused) {
|
||||
t.Fatalf("refusal %v does not carry ErrRefused", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStayOnOriginNamesTheHostItWasSentTo(t *testing.T) {
|
||||
first, _ := http.NewRequest(http.MethodPost, "https://example.com/v1", nil)
|
||||
next, _ := http.NewRequest(http.MethodPost, "https://www.example.com/v1?token=x", nil)
|
||||
err := StayOnOrigin()(next, []*http.Request{first})
|
||||
var left *OriginLeft
|
||||
if !errors.As(err, &left) || left.From != "example.com" || left.To != "www.example.com" {
|
||||
t.Fatalf("err = %#v, want OriginLeft example.com -> www.example.com", err)
|
||||
}
|
||||
if !errors.Is(err, ErrRefused) || strings.Contains(err.Error(), "token") {
|
||||
t.Fatalf("err = %v: must be ErrRefused and carry no query", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalsAreRecognisedAndPermanent(t *testing.T) {
|
||||
var p interface{ Permanent() bool }
|
||||
if !errors.As(ErrRefused, &p) || !p.Permanent() {
|
||||
t.Fatal("ErrRefused must report itself permanent")
|
||||
}
|
||||
if err := Follow(releaseHosts...)(&http.Request{}, nil); !errors.Is(err, ErrRefused) {
|
||||
t.Fatalf("a request with no URL = %v, want ErrRefused", err)
|
||||
}
|
||||
empty, _ := http.NewRequest(http.MethodGet, "https:///x", nil)
|
||||
if err := Follow(releaseHosts...)(empty, nil); !errors.Is(err, ErrRefused) {
|
||||
t.Fatalf("a URL with no hostname = %v, want ErrRefused", err)
|
||||
}
|
||||
if err := StayOnOrigin()(empty, nil); !errors.Is(err, ErrRefused) {
|
||||
t.Fatalf("no earlier request to stay on = %v, want ErrRefused", err)
|
||||
}
|
||||
if permitted("", releaseHosts) || permitted(" . ", releaseHosts) {
|
||||
t.Fatal("an empty host was permitted")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/safety/redirectguard"
|
||||
)
|
||||
|
||||
const DefaultBaseURL = "https://api.typesafe.ai"
|
||||
@@ -90,7 +92,7 @@ func (c Client) Evaluate(ctx context.Context, request Request) (Response, error)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
client := c.HTTP
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
client = &http.Client{CheckRedirect: redirectguard.StayOnOrigin()}
|
||||
}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package typesafe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluateWithoutAClientDoesNotFollowARedirectWithTheKey(t *testing.T) {
|
||||
var reached atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached.Add(1) }))
|
||||
defer elsewhere.Close()
|
||||
home := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer home.Close()
|
||||
_, err := (Client{BaseURL: home.URL, APIKey: func() string { return "secret" }}).Evaluate(context.Background(), Request{State: "s", Model: "m"})
|
||||
if err == nil {
|
||||
t.Fatal("a cross-origin redirect was followed")
|
||||
}
|
||||
if n := reached.Load(); n != 0 {
|
||||
t.Fatalf("redirect target received %d request(s)", n)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"reasonix/internal/base/netclient"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/safety/endpointclient"
|
||||
)
|
||||
|
||||
// NetworkSettings is the proxy configuration as an editor needs it: the mode,
|
||||
@@ -123,7 +124,7 @@ func (c *Controller) probeProviderAuth(ctx context.Context, cfg *config.Config,
|
||||
probe.DurationMs = time.Since(started).Milliseconds()
|
||||
return probe
|
||||
}
|
||||
client, err := netclient.NewHTTPClient(spec, netclient.TransportOptions{DialTimeout: 8 * time.Second})
|
||||
client, err := endpointclient.New(spec, netclient.TransportOptions{DialTimeout: 8 * time.Second})
|
||||
if err != nil {
|
||||
probe.Detail = err.Error()
|
||||
return probe
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"internal/ext/pluginpkg/**": 82.7,
|
||||
"internal/model/providerbroker/**": 83.1,
|
||||
"internal/safety/redirectguard/**": 92.6,
|
||||
"internal/safety/endpointclient/**": 80,
|
||||
"internal/safety/egress/**": 77.1,
|
||||
"internal/safety/sandbox/**": 78.1,
|
||||
"internal/base/shellparse/**": 64.5,
|
||||
|
||||
Reference in new issue
Block a user