Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gateway/cmd/dsgate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func run() error {
Origins: envList("DSGATE_ORIGINS"),
AdminToken: os.Getenv("DSGATE_ADMIN_TOKEN"),
Announce: os.Getenv("DSGATE_ANNOUNCE"),
TurnstileSecret: os.Getenv("DSGATE_TURNSTILE_SECRET"),
}

gw := server.New(cfg, signer, m, ledger)
Expand Down
23 changes: 23 additions & 0 deletions gateway/internal/server/anon.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ type TokenRequest struct {
// through JavaScript's number type, and the playground is a first
// class client.
Nonce string `json:"nonce"`
// TurnstileToken is the browser check's answer, required on the
// browser lane when the gateway is configured with a Turnstile
// secret and ignored otherwise. See turnstile.go for the lane split.
TurnstileToken string `json:"turnstile_token,omitempty"`
}

// TokenResponse is a minted credential.
Expand Down Expand Up @@ -108,11 +112,30 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
return
}

// The browser lane owes a Turnstile answer on top of the solve. The
// missing-token case is refused before Redeem so the caller's valid
// proof-of-work is not burned learning they forgot the widget.
if s.turnstileRequired(r) && req.TurnstileToken == "" {
writeError(w, http.StatusForbidden, typeRejected,
"this origin also needs the browser check; reload the playground and try again")
return
}

t, err := s.mint.Redeem(ip, req.Challenge, nonce)
if err != nil {
writeError(w, http.StatusBadRequest, typeRejected, err.Error())
return
}

// Verified after Redeem, deliberately: reaching Cloudflare costs an
// outbound call, and this order means nobody can trigger one without
// first paying a valid proof-of-work.
if s.turnstileRequired(r) {
if err := s.verifyTurnstile(r.Context(), req.TurnstileToken, ip); err != nil {
writeError(w, http.StatusForbidden, typeRejected, err.Error())
return
}
}
writeJSON(w, http.StatusOK, TokenResponse{
Token: t.String,
Subject: t.Subject.String(),
Expand Down
10 changes: 10 additions & 0 deletions gateway/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ type Config struct {
// AdminToken gates the operator endpoints. Empty disables them.
AdminToken string

// TurnstileSecret enables the browser check on the mint's browser
// lane: when set, a token redemption that carries an Origin header
// must also carry a Cloudflare Turnstile token, verified against
// siteverify. Empty disables the check entirely; the CLI's
// no-Origin lane is never subject to it. See turnstile.go.
TurnstileSecret string
// TurnstileURL overrides the siteverify endpoint, for tests. Empty
// means Cloudflare's real one.
TurnstileURL string

// Announce is the public URL of this gateway, used in the messages
// that tell a user where their prompts are going.
Announce string
Expand Down
93 changes: 93 additions & 0 deletions gateway/internal/server/turnstile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package server

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)

// Turnstile is the browser check on the mint's browser lane.
//
// Proof-of-work prices an identity in CPU, which is the right currency
// for the CLI — a shell has nothing else to offer. A browser has more:
// it can demonstrate it is a real browser under real use, which is what
// Cloudflare Turnstile attests, and a farm of headless enrollers fails
// that check long before it runs out of CPU. So the two lanes differ:
//
// - No Origin header (the CLI, curl): proof-of-work alone, unchanged.
// - Origin header present (a browser via CORS): proof-of-work AND a
// Turnstile token, verified against Cloudflare before the solve is
// honoured.
//
// The split is honest about what it defends: it hardens the playground
// path against browser automation without pretending to gate the API
// itself — a direct caller still pays proof-of-work into the same
// budgets, which remain the real boundary.
//
// The whole feature is off until TurnstileSecret is configured, so the
// gateway runs identically with an empty config.

// turnstileVerifyURL is Cloudflare's siteverify endpoint; Config may
// override it, which is how the tests stand in a fake Cloudflare.
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"

// turnstileRequired says whether this request is on the browser lane.
// CORS preflight means every cross-origin browser call carries Origin;
// the CLI never sends one.
func (s *Server) turnstileRequired(r *http.Request) bool {
return s.cfg.TurnstileSecret != "" && r.Header.Get("Origin") != ""
}

type turnstileVerdict struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes"`
}

// verifyTurnstile confirms a widget token with Cloudflare. It is called
// only after the proof-of-work has been redeemed, so an attacker cannot
// make this gateway spam Cloudflare without first paying CPU.
func (s *Server) verifyTurnstile(ctx context.Context, token, ip string) error {
if token == "" {
return errors.New("this browser did not complete the check")
}
endpoint := s.cfg.TurnstileURL
if endpoint == "" {
endpoint = turnstileVerifyURL
}
form := url.Values{
"secret": {s.cfg.TurnstileSecret},
"response": {token},
"remoteip": {ip},
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("could not build the verification request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

res, err := s.http.Do(req)
if err != nil {
return fmt.Errorf("could not reach the verifier: %w", err)
}
defer res.Body.Close()

var v turnstileVerdict
if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
return fmt.Errorf("unreadable verifier answer: %w", err)
}
if !v.Success {
if len(v.ErrorCodes) > 0 {
return fmt.Errorf("the browser check failed (%s)", strings.Join(v.ErrorCodes, ", "))
}
return errors.New("the browser check failed")
}
return nil
}
180 changes: 180 additions & 0 deletions gateway/internal/server/turnstile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package server

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"

"github.com/thevibeworks/deepseek-cli/gateway/internal/quota"
"github.com/thevibeworks/deepseek-cli/gateway/internal/token"
)

// fakeSiteverify stands in for Cloudflare: "good-token" passes, anything
// else fails, and it counts calls so a test can prove the gateway never
// phoned out.
type fakeSiteverify struct {
server *httptest.Server
calls atomic.Int64
secret atomic.Value // last secret seen
}

func newFakeSiteverify() *fakeSiteverify {
f := &fakeSiteverify{}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.calls.Add(1)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
f.secret.Store(r.PostForm.Get("secret"))
ok := r.PostForm.Get("response") == "good-token"
out := map[string]any{"success": ok}
if !ok {
out["error-codes"] = []string{"invalid-input-response"}
}
json.NewEncoder(w).Encode(out)
}))
return f
}

// mint runs challenge -> solve -> redeem with an optional Origin header
// and Turnstile token, and returns the redemption response.
func (h *harness) mint(t *testing.T, origin, tsToken string) (*http.Response, string) {
t.Helper()

resp, err := h.client.Post(h.base+"/v1/anon/challenge", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var ch ChallengeResponse
if err := json.NewDecoder(resp.Body).Decode(&ch); err != nil {
t.Fatal(err)
}
nonce, err := token.Solve(ch.Challenge, ch.Difficulty, 1<<24)
if err != nil {
t.Fatal(err)
}

body, _ := json.Marshal(TokenRequest{
Challenge: ch.Challenge,
Nonce: strconv.FormatUint(nonce, 10),
TurnstileToken: tsToken,
})
req, err := http.NewRequest(http.MethodPost, h.base+"/v1/anon/token", strings.NewReader(string(body)))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
if origin != "" {
req.Header.Set("Origin", origin)
}
res, err := h.client.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(res.Body)
res.Body.Close()
return res, string(raw)
}

const playgroundOrigin = "https://deepseek-cli.example"

// quietUpstream is an upstream these tests never reach: minting is the
// whole journey here.
func quietUpstream(t *testing.T) *upstream {
t.Helper()
return newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "the mint tests should never proxy upstream", http.StatusTeapot)
})
}

func turnstileHarness(t *testing.T, f *fakeSiteverify) *harness {
t.Helper()
return newHarness(t, quietUpstream(t), func(c *Config, _ *quota.Limits) {
c.TurnstileSecret = "test-secret"
c.TurnstileURL = f.server.URL
})
}

func TestTurnstileBrowserLaneNeedsToken(t *testing.T) {
f := newFakeSiteverify()
defer f.server.Close()
h := turnstileHarness(t, f)

res, body := h.mint(t, playgroundOrigin, "")
if res.StatusCode != http.StatusForbidden {
t.Fatalf("browser mint without turnstile: HTTP %d, want 403 (%s)", res.StatusCode, body)
}
if !strings.Contains(body, "browser check") {
t.Fatalf("the refusal should say what to do, got: %s", body)
}
// The missing-token case must be refused locally: nobody may spend
// our outbound calls without paying proof-of-work... and this caller
// did pay, but the refusal happens before Redeem, so no call either.
if n := f.calls.Load(); n != 0 {
t.Fatalf("siteverify was called %d times for a missing token", n)
}
}

func TestTurnstileBrowserLaneBadToken(t *testing.T) {
f := newFakeSiteverify()
defer f.server.Close()
h := turnstileHarness(t, f)

res, body := h.mint(t, playgroundOrigin, "forged")
if res.StatusCode != http.StatusForbidden {
t.Fatalf("browser mint with a bad turnstile token: HTTP %d, want 403 (%s)", res.StatusCode, body)
}
if f.calls.Load() != 1 {
t.Fatalf("siteverify calls = %d, want 1", f.calls.Load())
}
}

func TestTurnstileBrowserLaneGoodToken(t *testing.T) {
f := newFakeSiteverify()
defer f.server.Close()
h := turnstileHarness(t, f)

res, body := h.mint(t, playgroundOrigin, "good-token")
if res.StatusCode != http.StatusOK {
t.Fatalf("browser mint with a good turnstile token: HTTP %d (%s)", res.StatusCode, body)
}
var tr TokenResponse
if err := json.Unmarshal([]byte(body), &tr); err != nil || tr.Token == "" {
t.Fatalf("no token in %s", body)
}
if got := f.secret.Load(); got != "test-secret" {
t.Fatalf("siteverify saw secret %q", got)
}
}

func TestTurnstileCLILaneUnaffected(t *testing.T) {
f := newFakeSiteverify()
defer f.server.Close()
h := turnstileHarness(t, f)

// No Origin header: the CLI lane. Pure proof-of-work, no widget.
res, body := h.mint(t, "", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("CLI mint under turnstile config: HTTP %d (%s)", res.StatusCode, body)
}
if n := f.calls.Load(); n != 0 {
t.Fatalf("the CLI lane reached siteverify %d times", n)
}
}

func TestTurnstileOffByDefault(t *testing.T) {
h := newHarness(t, quietUpstream(t), nil)

// Browser-origin mint with no turnstile configured: unchanged.
res, body := h.mint(t, playgroundOrigin, "")
if res.StatusCode != http.StatusOK {
t.Fatalf("browser mint with turnstile unconfigured: HTTP %d (%s)", res.StatusCode, body)
}
}
4 changes: 4 additions & 0 deletions gateway/internal/server/web/pages/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ <h2>What leaves for DeepSeek</h2>
<p class="warn">Your prompt does not stay on your machine. It transits this gateway and is forwarded to api.deepseek.com, where it is processed under DeepSeek's own <a href="https://cdn.deepseek.com/policies/en-US/deepseek-open-platform-terms-of-service.html">terms of service</a> and privacy practices. We pass the bytes through and do not keep them, but DeepSeek receives them the same as if you had called the API yourself. Do not send anything sensitive through this service. For sensitive work, bring your own key from <a href="https://platform.deepseek.com">platform.deepseek.com</a> and skip the proxy entirely.</p>
<p>Each forwarded request carries your anonymous subject id as DeepSeek's <code>user_id</code> field. This is the mechanism DeepSeek provides for one account fronting many users: it attributes content-safety events to the individual subject rather than the whole pool, and it keeps each subject's prompt cache isolated from strangers'.</p>

<h2>What leaves for Cloudflare</h2>
<p>Enrolling <em>from a browser</em> can be asked to pass a Cloudflare Turnstile check, on top of the proof-of-work every caller pays. When that check is switched on, two things reach Cloudflare that otherwise would not: your browser loads their widget script, and this gateway posts the widget's answer to their siteverify endpoint together with the IP address of your connection, because that is the input their check is scored on. Nothing else goes with it — no prompt, no completion, no subject id, no token. The result we keep is one bit, pass or fail, and it is not written anywhere.</p>
<p>The check applies only to the browser lane. Enrolling from the CLI or curl sends no <code>Origin</code> header, is never subject to it, and reaches no third party but DeepSeek. If you would rather not be scored by Cloudflare at all, enrol with <code>deepseek free on</code> instead of the playground.</p>

<h2>Geography</h2>
<p>The dashboard shows where traffic comes from as a per-country histogram. The input is a two-letter country code supplied by the network edge; no IP address ever reaches the code that counts it. The histogram is aggregate only, is never linked to a subject, lives in memory, and is lost whenever the gateway restarts. A country total is a fact about the service, not about a person.</p>

Expand Down