feat: 抹除 Anthropic OAuth 请求中客户端 dateline 隐写指纹

对 /v1/messages 转发到 Anthropic OAuth/setup-token 账号的请求做 dateline
归一化,将 system prompt 与 <system-reminder> 块中 "Today's date is …"
语句里的 4 种撇号变体与 "/" 日期分隔符还原为 ASCII 撇号 + "-",抹除某些
客户端在检测到非官方 base URL 时注入的 3 bit 隐写指纹。API Key 账号不受
影响。新增系统设置开关 enable_client_dateline_normalization,默认开启。
This commit is contained in:
shaw
2026-07-01 10:54:18 +08:00
parent c1335bae12
commit 59e9356c51
15 changed files with 782 additions and 2 deletions
@@ -259,6 +259,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
MinCodexVersion: settings.MinCodexVersion,
@@ -598,6 +599,7 @@ type UpdateSettingsRequest struct {
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
@@ -1731,6 +1733,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RewriteMessageCacheControl
}(),
EnableClientDatelineNormalization: func() bool {
if req.EnableClientDatelineNormalization != nil {
return *req.EnableClientDatelineNormalization
}
return previousSettings.EnableClientDatelineNormalization
}(),
AntigravityUserAgentVersion: func() string {
if req.AntigravityUserAgentVersion != nil {
return *req.AntigravityUserAgentVersion
@@ -2143,6 +2151,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
MinCodexVersion: updatedSettings.MinCodexVersion,
@@ -2644,6 +2653,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl {
changed = append(changed, "rewrite_message_cache_control")
}
if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization {
changed = append(changed, "enable_client_dateline_normalization")
}
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
changed = append(changed, "antigravity_user_agent_version")
}
+1
View File
@@ -186,6 +186,7 @@ type SystemSettings struct {
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
@@ -0,0 +1,288 @@
// Package anthropicfp provides pure helpers for suppressing client-side
// fingerprints that would otherwise be visible to upstream Anthropic when a
// forwarding gateway sits between the client and api.anthropic.com.
//
// Currently exposes NormalizeDateline: it rewrites the "Today's date is
// YYYY-MM-DD." sentence inside a request body back to a canonical ASCII form,
// erasing three bits of steganographic signal (four apostrophe code points and
// a date-separator variant) that some clients embed in that sentence when
// they detect a non-official base URL.
package anthropicfp
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// datelineRegexes matches the fingerprinted sentence with any of the four
// apostrophe code points seen in the wild and either separator. Two regexes
// are used because Go's RE2-based regexp package does not support
// backreferences: matching `-` and `/` in two passes keeps the two separators
// inside YYYY?MM?DD forced to agree, so mixed-separator strings like
// "Today's date is 2026-07/01." never match. This is what filters out
// user-authored prose like "Today is foo." or "His date is 2026-06-30." from
// being touched.
var (
datelineRegexHyphen = regexp.MustCompile("Today(['’ʼʹ])s date is (\\d{4})-(\\d{2})-(\\d{2})\\.")
datelineRegexSlash = regexp.MustCompile("Today(['’ʼʹ])s date is (\\d{4})/(\\d{2})/(\\d{2})\\.")
)
// systemReminderRegex matches a <system-reminder> block. The dateline lives in
// this block once the conversation has advanced past the first turn (system
// prompt caching hides the top-level system block for subsequent turns), so
// the messages[].content[] scan is confined to what lives inside these tags.
var systemReminderRegex = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
// DatelineHit records what a single rewrite normalized, for observability.
type DatelineHit struct {
// ApostropheVariant is one of "ascii" (U+0027), "u2019", "u02bc", "u02b9".
ApostropheVariant string
// DateSeparator is either "-" or "/" as seen before normalization.
DateSeparator string
}
// canonicalize returns the canonical form of a matched dateline sentence.
// The output always uses ASCII apostrophe and hyphen separators.
func canonicalize(year, month, day string) string {
return fmt.Sprintf("Today's date is %s-%s-%s.", year, month, day)
}
func apostropheVariant(r rune) string {
switch r {
case '’':
return "u2019"
case 'ʼ':
return "u02bc"
case 'ʹ':
return "u02b9"
default:
return "ascii"
}
}
type datelineMatch struct {
start, end int
apoRune rune
sep string
year, month, day string
}
func collectMatches(text string, re *regexp.Regexp, sep string) []datelineMatch {
locs := re.FindAllStringSubmatchIndex(text, -1)
if len(locs) == 0 {
return nil
}
out := make([]datelineMatch, 0, len(locs))
for _, m := range locs {
var apoRune rune
for _, r := range text[m[2]:m[3]] {
apoRune = r
break
}
out = append(out, datelineMatch{
start: m[0],
end: m[1],
apoRune: apoRune,
sep: sep,
year: text[m[4]:m[5]],
month: text[m[6]:m[7]],
day: text[m[8]:m[9]],
})
}
return out
}
// NormalizeText replaces every fingerprinted dateline sentence in text with
// its canonical form. It returns the possibly-rewritten text and the list of
// hits observed. When no match is found the original string is returned
// verbatim (byte-identical), and the returned hit slice is nil.
func NormalizeText(text string) (string, []DatelineHit) {
if !strings.Contains(text, "date is ") {
return text, nil
}
matches := collectMatches(text, datelineRegexHyphen, "-")
matches = append(matches, collectMatches(text, datelineRegexSlash, "/")...)
if len(matches) == 0 {
return text, nil
}
sort.Slice(matches, func(i, j int) bool { return matches[i].start < matches[j].start })
var b strings.Builder
b.Grow(len(text))
prev := 0
hits := make([]DatelineHit, 0, len(matches))
changed := false
for _, m := range matches {
full := text[m.start:m.end]
canonical := canonicalize(m.year, m.month, m.day)
if canonical == full {
// Already canonical: no rewrite, no hit.
continue
}
b.WriteString(text[prev:m.start])
b.WriteString(canonical)
prev = m.end
changed = true
hits = append(hits, DatelineHit{
ApostropheVariant: apostropheVariant(m.apoRune),
DateSeparator: m.sep,
})
}
if !changed {
return text, nil
}
b.WriteString(text[prev:])
return b.String(), hits
}
// normalizeSystemReminderScopedText scans only the <system-reminder> blocks
// inside text and normalizes datelines inside them. Text outside the blocks is
// preserved byte-for-byte, so user prose, tool_result content, code blocks,
// or shell commands that happen to contain an apostrophe or a slash date are
// never touched.
func normalizeSystemReminderScopedText(text string) (string, []DatelineHit) {
if !strings.Contains(text, "<system-reminder>") {
return text, nil
}
locs := systemReminderRegex.FindAllStringIndex(text, -1)
if len(locs) == 0 {
return text, nil
}
var b strings.Builder
b.Grow(len(text))
prev := 0
var hits []DatelineHit
changed := false
for _, loc := range locs {
b.WriteString(text[prev:loc[0]])
block := text[loc[0]:loc[1]]
normalized, blockHits := NormalizeText(block)
if normalized != block {
changed = true
}
b.WriteString(normalized)
hits = append(hits, blockHits...)
prev = loc[1]
}
if !changed {
return text, nil
}
b.WriteString(text[prev:])
return b.String(), hits
}
// NormalizeDateline scans an Anthropic /v1/messages request body and rewrites
// every fingerprinted dateline sentence back to its canonical ASCII form.
//
// Scope (mirroring where genuine clients place the sentence):
// - `system` string, or `.text` field of each text-typed block in `system`.
// - Text bodies inside `messages[i].content` — but ONLY the substrings that
// appear inside `<system-reminder>...</system-reminder>` tags. Free user
// prose, tool_use.input, tool_result.content, and other block types are
// never scanned, guaranteeing that legitimate text like a code block, a
// shell command, or a chat message that mentions today's date is never
// accidentally rewritten.
//
// The function is a pure transform: it never modifies the input slice, and if
// no rewrite is needed it returns the original slice (identity), a nil hit
// slice, and changed=false.
func NormalizeDateline(body []byte) ([]byte, []DatelineHit, bool) {
if len(body) == 0 {
return body, nil, false
}
out := body
var hits []DatelineHit
changed := false
sys := gjson.GetBytes(out, "system")
if sys.Exists() {
switch {
case sys.Type == gjson.String:
normalized, sysHits := NormalizeText(sys.String())
if normalized != sys.String() {
if next, err := sjson.SetBytes(out, "system", normalized); err == nil {
out = next
changed = true
hits = append(hits, sysHits...)
}
}
case sys.IsArray():
idx := 0
sys.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "text" {
t := item.Get("text")
if t.Exists() && t.Type == gjson.String {
normalized, textHits := NormalizeText(t.String())
if normalized != t.String() {
path := fmt.Sprintf("system.%d.text", idx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, textHits...)
}
}
}
}
idx++
return true
})
}
}
messages := gjson.GetBytes(out, "messages")
if messages.IsArray() {
msgIdx := -1
messages.ForEach(func(_, msg gjson.Result) bool {
msgIdx++
content := msg.Get("content")
if !content.Exists() {
return true
}
switch {
case content.Type == gjson.String:
normalized, contentHits := normalizeSystemReminderScopedText(content.String())
if normalized != content.String() {
path := fmt.Sprintf("messages.%d.content", msgIdx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, contentHits...)
}
}
case content.IsArray():
contentIdx := -1
content.ForEach(func(_, block gjson.Result) bool {
contentIdx++
if block.Get("type").String() != "text" {
return true
}
t := block.Get("text")
if !t.Exists() || t.Type != gjson.String {
return true
}
normalized, textHits := normalizeSystemReminderScopedText(t.String())
if normalized != t.String() {
path := fmt.Sprintf("messages.%d.content.%d.text", msgIdx, contentIdx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, textHits...)
}
}
return true
})
}
return true
})
}
if !changed {
return body, nil, false
}
return out, hits, true
}
@@ -0,0 +1,248 @@
package anthropicfp
import (
"bytes"
"strings"
"testing"
)
func TestNormalizeText_ASCIIHyphenIsIdentity(t *testing.T) {
in := "Today's date is 2026-07-01."
out, hits := NormalizeText(in)
if out != in {
t.Fatalf("canonical form should be returned identity, got %q", out)
}
if len(hits) != 0 {
t.Fatalf("no hits expected on canonical input, got %d", len(hits))
}
}
func TestNormalizeText_SlashSeparatorASCIIApostrophe(t *testing.T) {
in := "Today's date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "ascii" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U2019Apostrophe(t *testing.T) {
in := "Today’s date is 2026-07-01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u2019" || hits[0].DateSeparator != "-" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U02BCApostropheWithSlash(t *testing.T) {
in := "Todayʼs date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U02B9Apostrophe(t *testing.T) {
in := "Todayʹs date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02b9" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_MixedSeparatorsNoMatch(t *testing.T) {
// backreference \3 forces the two separators to agree
in := "Today's date is 2026-07/01."
out, hits := NormalizeText(in)
if out != in {
t.Fatalf("mixed-separator input should not be matched, got %q", out)
}
if len(hits) != 0 {
t.Fatalf("expected no hits, got %d", len(hits))
}
}
func TestNormalizeText_NegativeLookalike(t *testing.T) {
cases := []string{
"Today is a great day.",
"His date is 2026-07-01.",
"Yesterday's date was 2026-06-30.",
"'s date is 2026-07-01.",
}
for _, c := range cases {
out, hits := NormalizeText(c)
if out != c {
t.Fatalf("input %q should not be modified, got %q", c, out)
}
if len(hits) != 0 {
t.Fatalf("input %q should produce no hits", c)
}
}
}
func TestNormalizeText_Idempotent(t *testing.T) {
in := "Today’s date is 2026/07/01."
out1, _ := NormalizeText(in)
out2, hits2 := NormalizeText(out1)
if out1 != out2 {
t.Fatalf("normalization not idempotent: %q vs %q", out1, out2)
}
if len(hits2) != 0 {
t.Fatalf("second pass should produce no hits, got %d", len(hits2))
}
}
func TestNormalizeText_MultipleOccurrences(t *testing.T) {
in := "First line.\nToday’s date is 2026/07/01.\nMore text.\nTodayʼs date is 2026-07-01.\nEnd."
out, hits := NormalizeText(in)
if strings.Count(out, "Today's date is") != 2 {
t.Fatalf("expected two canonicalized sentences, got: %q", out)
}
if strings.Contains(out, "’") || strings.Contains(out, "ʼ") || strings.Contains(out, "2026/07/01") {
t.Fatalf("fingerprint characters must be gone, got: %q", out)
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
}
func TestNormalizeDateline_SystemString(t *testing.T) {
body := []byte(`{"system":"You are helpful.\nToday’s date is 2026/07/01.\nBe brief.","messages":[]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("output missing canonical dateline: %s", string(out))
}
if bytes.Contains(out, []byte("2026/07/01")) {
t.Fatalf("output should not contain slash date: %s", string(out))
}
}
func TestNormalizeDateline_SystemBlocksArray(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"You are helpful."},{"type":"text","text":"Todayʼs date is 2026/07/01."}],"messages":[]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" {
t.Fatalf("unexpected hits: %+v", hits)
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("output missing canonical dateline: %s", string(out))
}
}
func TestNormalizeDateline_MessagesContentStringInSystemReminder(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\n# currentDate\nToday’s date is 2026/07/01.\n</system-reminder>\nHello, please help."}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("canonical dateline missing: %s", string(out))
}
}
func TestNormalizeDateline_MessagesContentBlocksInSystemReminder(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"},{"type":"text","text":"do X"}]}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("canonical dateline missing: %s", string(out))
}
}
func TestNormalizeDateline_LeavesOutOfScopeUntouched(t *testing.T) {
// User prose outside <system-reminder> that mentions today's date must
// not be modified. tool_use.input / tool_result.content are never scanned.
body := []byte(`{"messages":[` +
`{"role":"user","content":"Today’s date is 2026/07/01. Please help."},` +
`{"role":"assistant","content":[{"type":"tool_use","id":"x","name":"y","input":{"note":"Today’s date is 2026/07/01."}}]},` +
`{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"log: Today’s date is 2026/07/01."}]}` +
`]}`)
out, hits, changed := NormalizeDateline(body)
if changed {
t.Fatalf("expected changed=false, hits=%v out=%s", hits, string(out))
}
if !bytes.Equal(out, body) {
t.Fatalf("output should equal input byte-for-byte")
}
}
func TestNormalizeDateline_Idempotent(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
first, _, changed1 := NormalizeDateline(body)
if !changed1 {
t.Fatalf("expected first pass to change body")
}
second, _, changed2 := NormalizeDateline(first)
if changed2 {
t.Fatalf("second pass should not report changes")
}
if !bytes.Equal(first, second) {
t.Fatalf("second pass diverged: %s vs %s", string(first), string(second))
}
}
func TestNormalizeDateline_EmptyBody(t *testing.T) {
out, hits, changed := NormalizeDateline(nil)
if changed || out != nil || hits != nil {
t.Fatalf("empty body should be no-op")
}
}
func TestNormalizeDateline_NoDateline(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"hello"}],"system":"just a system prompt"}`)
out, hits, changed := NormalizeDateline(body)
if changed || len(hits) != 0 {
t.Fatalf("expected no changes; changed=%v hits=%v", changed, hits)
}
if &out[0] != &body[0] {
// Identity is a bonus but not strict; verify content equality at minimum
if !bytes.Equal(out, body) {
t.Fatalf("output must byte-match input when no changes needed")
}
}
}
func TestNormalizeDateline_MultipleSystemReminderBlocksInSameText(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>\nsome prose\n<system-reminder>\nAlso Todayʼs date is 2026/07/01.\n</system-reminder>"}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
if bytes.Contains(out, []byte("2026/07/01")) {
t.Fatalf("slash separator must be gone: %s", string(out))
}
}
@@ -846,6 +846,7 @@ func TestAPIContracts(t *testing.T) {
"claude_oauth_system_prompt_blocks": "",
"enable_anthropic_cache_ttl_1h_injection": false,
"rewrite_message_cache_control": false,
"enable_client_dateline_normalization": true,
"antigravity_user_agent_version": "",
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
@@ -1090,6 +1091,7 @@ func TestAPIContracts(t *testing.T) {
"claude_oauth_system_prompt_blocks": "",
"enable_anthropic_cache_ttl_1h_injection": false,
"rewrite_message_cache_control": false,
"enable_client_dateline_normalization": true,
"antigravity_user_agent_version": "",
"min_codex_version": "",
"max_codex_version": "",
@@ -450,6 +450,13 @@ const (
SettingKeyClaudeOAuthSystemPromptBlocks = "claude_oauth_system_prompt_blocks"
// SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection"
// SettingKeyEnableClientDatelineNormalization 是否对 Anthropic OAuth/SetupToken 账号
// 的 /v1/messages 请求体做客户端 dateline 归一化(默认 true)。
// 归一化把 system prompt / <system-reminder> 块中 "Today's date is …" 语句里的
// 非 ASCII 撇号与 "/" 日期分隔符还原为 ASCII 撇号 + "-" 分隔符,抹除某些客户端
// 在检测到非官方 base URL 时注入的 3 bit 隐写指纹。仅适用于 Anthropic OAuth/SetupToken
// 账号;API Key 账号不受影响。
SettingKeyEnableClientDatelineNormalization = "enable_client_dateline_normalization"
// SettingKeyRewriteMessageCacheControl 是否改写 messages[*].content[*].cache_control(默认 false)
SettingKeyRewriteMessageCacheControl = "rewrite_message_cache_control"
// SettingKeyAntigravityUserAgentVersion Antigravity 上游 User-Agent 版本号(空值使用环境变量/默认值)
@@ -0,0 +1,123 @@
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp"
"github.com/stretchr/testify/require"
)
// TestGatewayClientDatelineNormalization_Scope covers the account/switch matrix
// for the shouldNormalizeClientDateline gate: Anthropic OAuth/SetupToken pass
// only when the switch is on; API-Key and non-Anthropic platforms are excluded
// unconditionally.
func TestGatewayClientDatelineNormalization_Scope(t *testing.T) {
repo := &gatewayTTLSettingRepo{data: map[string]string{}}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
svc := &GatewayService{
settingService: NewSettingService(repo, &config.Config{}),
}
ctx := context.Background()
// Default (missing key): fallback in parseSettings/cache loader is true.
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}))
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
// Switch off: no account qualifies.
repo.data[SettingKeyEnableClientDatelineNormalization] = "false"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
// Switch back on: OAuth qualifies again.
repo.data[SettingKeyEnableClientDatelineNormalization] = "true"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
}
// TestGatewayClientDatelineNormalization_HelperNoRewrite exercises the code
// path used by Forward: the helper must return ok=false when the switch is
// off, when the account is API-Key, when the account is nil, and when the
// body carries no fingerprinted dateline. It must return ok=true and a
// rewritten body when both the switch is on and the account is Anthropic
// OAuth/SetupToken and a rewrite actually happened.
func TestGatewayClientDatelineNormalization_HelperNoRewrite(t *testing.T) {
repo := &gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableClientDatelineNormalization: "true",
}}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
svc := &GatewayService{
settingService: NewSettingService(repo, &config.Config{}),
}
ctx := context.Background()
dirty := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
clean := []byte(`{"messages":[{"role":"user","content":"just hello"}]}`)
// API-Key account: never rewrites, even with dirty payload.
next, ok := svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, dirty)
require.False(t, ok)
require.Nil(t, next)
// Nil account: safe no-op.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, nil, dirty)
require.False(t, ok)
require.Nil(t, next)
// OAuth account + clean body: no changes, ok=false.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, clean)
require.False(t, ok)
require.Nil(t, next)
// OAuth account + dirty body: rewritten, ok=true.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, dirty)
require.True(t, ok)
require.NotNil(t, next)
require.Contains(t, string(next), "Today's date is 2026-07-01.")
require.NotContains(t, string(next), "2026/07/01")
require.NotContains(t, string(next), "Today’s date is")
// SetupToken account + dirty body: rewritten, ok=true.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, dirty)
require.True(t, ok)
require.Contains(t, string(next), "Today's date is 2026-07-01.")
// Switch off: even OAuth account is not rewritten.
repo.data[SettingKeyEnableClientDatelineNormalization] = "false"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, dirty)
require.False(t, ok)
require.Nil(t, next)
}
// TestGatewayClientDatelineNormalization_LeavesUserProseUntouched double-checks
// that the pure normalizer never touches content outside <system-reminder>
// blocks. This is an integration guard between the switch-gated helper and
// the pkg/anthropicfp scope contract, tripped by anyone who broadens scope.
func TestGatewayClientDatelineNormalization_LeavesUserProseUntouched(t *testing.T) {
repo := &gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableClientDatelineNormalization: "true",
}}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
svc := &GatewayService{
settingService: NewSettingService(repo, &config.Config{}),
}
ctx := context.Background()
// User prose that happens to include a fingerprint-looking sentence
// (outside <system-reminder>) must be preserved byte-for-byte.
body := []byte(`{"messages":[{"role":"user","content":"I wrote: Today’s date is 2026/07/01. What do you think?"}]}`)
next, ok := svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, body)
require.False(t, ok, "must not rewrite user prose outside <system-reminder>")
require.Nil(t, next)
// Direct pure-fn check for redundancy.
out, hits, changed := anthropicfp.NormalizeDateline(body)
require.False(t, changed)
require.Empty(t, hits)
require.Equal(t, body, out)
}
@@ -26,6 +26,7 @@ import (
"unsafe"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -4781,6 +4782,32 @@ func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, ac
return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx)
}
// shouldNormalizeClientDateline reports whether the request body's client
// dateline should be normalized before forwarding to Anthropic. The switch is
// scoped to Anthropic OAuth/SetupToken accounts only; API-Key accounts and
// non-Anthropic platforms bypass this step entirely.
func (s *GatewayService) shouldNormalizeClientDateline(ctx context.Context, account *Account) bool {
if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil {
return false
}
return s.settingService.IsClientDatelineNormalizationEnabled(ctx)
}
// normalizeClientDatelineIfEnabled applies dateline normalization to body when
// the switch is on and the account qualifies. Returns (nextBody, true) only
// when the body actually changed; otherwise returns (nil, false) so callers
// can skip the writeback.
func (s *GatewayService) normalizeClientDatelineIfEnabled(ctx context.Context, account *Account, body []byte) ([]byte, bool) {
if !s.shouldNormalizeClientDateline(ctx, account) {
return nil, false
}
next, _, changed := anthropicfp.NormalizeDateline(body)
if !changed {
return nil, false
}
return next, true
}
func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Context) (bool, string, string) {
if s == nil || s.settingService == nil {
return true, "", ""
@@ -4930,6 +4957,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
}
// 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。
// 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期
// 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外,
// 保证真实 Claude Code 客户端注入的指纹同样被清洗。
if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok {
if err := replaceBody(next); err != nil {
return nil, err
}
}
// 强制执行 cache_control 块数量限制(最多 4 个)
if err := replaceBody(enforceCacheControlLimit(body)); err != nil {
return nil, err
+28 -2
View File
@@ -112,6 +112,7 @@ type cachedGatewayForwardingSettings struct {
claudeOAuthSystemPromptBlocks string
anthropicCacheTTL1hInjection bool
rewriteMessageCacheControl bool
clientDatelineNormalization bool
expiresAt int64 // unix nano
}
@@ -2207,6 +2208,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
// codex_cli_only 加固
@@ -2345,6 +2347,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
rewriteMessageCacheControl: settings.RewriteMessageCacheControl,
clientDatelineNormalization: settings.EnableClientDatelineNormalization,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
s.antigravityUAVersionSF.Forget("antigravity_user_agent_version")
@@ -2548,6 +2551,7 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
type gatewayForwardingSettingsResult struct {
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
clientDatelineNormalization bool
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
}
@@ -2563,6 +2567,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
clientDatelineNormalization: cached.clientDatelineNormalization,
}
}
}
@@ -2578,6 +2583,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
clientDatelineNormalization: cached.clientDatelineNormalization,
}, nil
}
}
@@ -2592,6 +2598,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
SettingKeyClaudeOAuthSystemPromptBlocks,
SettingKeyEnableAnthropicCacheTTL1hInjection,
SettingKeyRewriteMessageCacheControl,
SettingKeyEnableClientDatelineNormalization,
})
if err != nil {
slog.Warn("failed to get gateway forwarding settings", "error", err)
@@ -2602,9 +2609,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptInjection: true,
anthropicCacheTTL1hInjection: false,
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
clientDatelineNormalization: true,
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
}
fp := true
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
@@ -2623,6 +2631,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
rewriteMessageCacheControl = v == "true"
}
clientDatelineNormalization := true
if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
clientDatelineNormalization = v == "true"
}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
fingerprintUnification: fp,
metadataPassthrough: mp,
@@ -2632,6 +2644,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
anthropicCacheTTL1hInjection: cacheTTL1h,
rewriteMessageCacheControl: rewriteMessageCacheControl,
clientDatelineNormalization: clientDatelineNormalization,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{
@@ -2643,12 +2656,13 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
cacheTTL1h: cacheTTL1h,
rewriteMessageCacheControl: rewriteMessageCacheControl,
clientDatelineNormalization: clientDatelineNormalization,
}, nil
})
if r, ok := val.(gatewayForwardingSettingsResult); ok {
return r
}
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true}
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
}
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
@@ -2669,6 +2683,12 @@ func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
}
// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
// 的客户端 dateline 归一化。默认开启。
func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
}
// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
// system block switch, legacy custom expansion prompt, and configurable blocks JSON.
// Empty values mean use the built-in Claude Code default blocks.
@@ -3173,6 +3193,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyAllowUngroupedKeyScheduling: "false",
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
SettingKeyEnableClientDatelineNormalization: "true",
SettingKeyAntigravityUserAgentVersion: "",
SettingKeyOpenAICodexUserAgent: "",
SettingPaymentVisibleMethodAlipaySource: "",
@@ -3711,6 +3732,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else {
result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl()
}
if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
result.EnableClientDatelineNormalization = v == "true"
} else {
result.EnableClientDatelineNormalization = true
}
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
// codex_cli_only 加固
@@ -197,6 +197,7 @@ type SystemSettings struct {
ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认
ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认
EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true)
RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认
+2
View File
@@ -561,6 +561,7 @@ export interface SystemSettings {
claude_oauth_system_prompt_blocks: string;
enable_anthropic_cache_ttl_1h_injection: boolean;
rewrite_message_cache_control: boolean;
enable_client_dateline_normalization: boolean;
antigravity_user_agent_version: string;
openai_codex_user_agent: string;
// codex_cli_only 加固
@@ -811,6 +812,7 @@ export interface UpdateSettingsRequest {
claude_oauth_system_prompt_blocks?: string;
enable_anthropic_cache_ttl_1h_injection?: boolean;
rewrite_message_cache_control?: boolean;
enable_client_dateline_normalization?: boolean;
antigravity_user_agent_version?: string;
openai_codex_user_agent?: string;
// codex_cli_only 加固
+2
View File
@@ -5827,6 +5827,8 @@ export default {
anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.',
rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints',
rewriteMessageCacheControlHint: 'Default off: preserve client cache_control on message content blocks. When enabled, client breakpoints are stripped and proxy breakpoints are injected for clients that do not manage caching themselves.',
clientDatelineNormalization: 'Client Dateline Normalization',
clientDatelineNormalizationHint: 'Default on. Rewrites the "Today\'s date is …" sentence in Anthropic OAuth/Setup Token requests back to a canonical ASCII apostrophe and hyphen date format, erasing steganographic fingerprint bits some clients inject when they detect a non-official base URL. Applies to system prompts and <system-reminder> blocks only; API-Key accounts are unaffected.',
antigravityUserAgentVersion: 'Antigravity UA Version',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.',
+2
View File
@@ -5981,6 +5981,8 @@ export default {
anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。',
rewriteMessageCacheControl: '改写消息缓存断点',
rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。',
clientDatelineNormalization: '客户端 dateline 归一化',
clientDatelineNormalizationHint: '默认开启。将 Anthropic OAuth/Setup Token 请求体中 "Today\'s date is …" 语句里的撇号与日期分隔符还原为 ASCII 撇号 + 短横线 (2026-07-01) 的规范形态,抹除某些客户端在检测到非官方 base URL 时注入的隐写指纹位。仅作用于 system prompt 与 <system-reminder> 块内,API Key 账号不受影响。',
antigravityUserAgentVersion: 'Antigravity UA 版本',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
+28
View File
@@ -4368,6 +4368,31 @@
<Toggle v-model="form.rewrite_message_cache_control" />
</div>
<!-- 客户端 dateline 归一化(仅 Anthropic OAuth/SetupToken) -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.clientDatelineNormalization",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.clientDatelineNormalizationHint",
)
}}
</p>
</div>
<Toggle
v-model="form.enable_client_dateline_normalization"
/>
</div>
<!-- Antigravity UA 版本 -->
<div>
<label
@@ -8078,6 +8103,7 @@ const form = reactive<SettingsForm>({
claude_oauth_system_prompt_blocks: defaultClaudeOAuthSystemPromptBlocks,
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
// codex_cli_only 加固
@@ -9282,6 +9308,8 @@ async function saveSettings() {
enable_anthropic_cache_ttl_1h_injection:
form.enable_anthropic_cache_ttl_1h_injection,
rewrite_message_cache_control: form.rewrite_message_cache_control,
enable_client_dateline_normalization:
form.enable_client_dateline_normalization,
antigravity_user_agent_version:
form.antigravity_user_agent_version?.trim() || "",
openai_codex_user_agent:
@@ -383,6 +383,7 @@ const baseSettingsResponse = {
claude_oauth_system_prompt_blocks: "",
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
payment_enabled: true,