From 4990ca0b2cb5b253a65fc0e0cf4dfc74040084c1 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Mon, 20 Jul 2026 00:10:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=20IP=20=E8=AF=B7=E6=B1=82=E5=A4=B4=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- frontend/src/views/admin/SettingsView.vue | 211 ++++++++++++++++++ .../admin/__tests__/SettingsView.spec.ts | 33 ++- 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c5c898ee85..bf749bb73b 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1654,6 +1654,66 @@ + +
+ +

+ {{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersHint") }} +

+
+
+ + {{ header }} + + +
+ +
+
+
+

+ {{ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersRiskHint") }} +

+
@@ -7754,6 +7814,7 @@ const smtpPasswordManuallyEdited = ref(false); const testEmailAddress = ref(""); const registrationEmailSuffixWhitelistTags = ref([]); const registrationEmailSuffixWhitelistDraft = ref(""); +const forwardedClientIpHeaderDraft = ref(""); const tablePageSizeOptionsInput = ref("10, 20, 50, 100"); // Admin API Key 状态 @@ -8394,6 +8455,7 @@ const form = reactive({ turnstile_secret_key: "", turnstile_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, + forwarded_client_ip_headers: [], // LinuxDo Connect OAuth 登录 linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", @@ -8972,6 +9034,143 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) { } } +const forwardedClientIpHeaderSeparatorKeys = new Set([ + " ", + ",", + ",", + "Enter", + "Tab", +]); +const forwardedClientIpHeaderTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const maxForwardedClientIpHeaders = 16; + +type ForwardedClientIpHeaderResult = "added" | "duplicate" | "invalid" | "full"; + +function normalizeForwardedClientIpHeader(raw: string): string { + const header = raw.trim(); + if (!forwardedClientIpHeaderTokenPattern.test(header)) { + return ""; + } + + return header + .toLowerCase() + .split("-") + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join("-"); +} + +function normalizeForwardedClientIpHeaders(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const headers: string[] = []; + const seen = new Set(); + for (const raw of value) { + if (typeof raw !== "string") { + continue; + } + const header = normalizeForwardedClientIpHeader(raw); + const key = header.toLowerCase(); + if (!header || seen.has(key) || headers.length >= maxForwardedClientIpHeaders) { + continue; + } + seen.add(key); + headers.push(header); + } + return headers; +} + +function removeForwardedClientIpHeader(header: string) { + form.forwarded_client_ip_headers = form.forwarded_client_ip_headers.filter( + (item) => item !== header, + ); +} + +function addForwardedClientIpHeader(raw: string): ForwardedClientIpHeaderResult { + const header = normalizeForwardedClientIpHeader(raw); + if (!header) { + return "invalid"; + } + if ( + form.forwarded_client_ip_headers.some( + (item) => item.toLowerCase() === header.toLowerCase(), + ) + ) { + return "duplicate"; + } + if (form.forwarded_client_ip_headers.length >= maxForwardedClientIpHeaders) { + return "full"; + } + form.forwarded_client_ip_headers = [ + ...form.forwarded_client_ip_headers, + header, + ]; + return "added"; +} + +function showForwardedClientIpHeaderError(result: ForwardedClientIpHeaderResult) { + if (result === "invalid") { + appStore.showError(t("admin.settings.apiKeyAcl.forwardedClientIpHeaderInvalid")); + } else if (result === "full") { + appStore.showError( + t("admin.settings.apiKeyAcl.forwardedClientIpHeadersLimit", { + max: maxForwardedClientIpHeaders, + }), + ); + } +} + +function commitForwardedClientIpHeaderDraft() { + const draft = forwardedClientIpHeaderDraft.value; + if (!draft) { + return; + } + const result = addForwardedClientIpHeader(draft); + showForwardedClientIpHeaderError(result); + forwardedClientIpHeaderDraft.value = ""; +} + +function handleForwardedClientIpHeaderKeydown(event: KeyboardEvent) { + if (event.isComposing) { + return; + } + if (forwardedClientIpHeaderSeparatorKeys.has(event.key)) { + event.preventDefault(); + commitForwardedClientIpHeaderDraft(); + return; + } + if ( + event.key === "Backspace" && + !forwardedClientIpHeaderDraft.value && + form.forwarded_client_ip_headers.length > 0 + ) { + form.forwarded_client_ip_headers.pop(); + } +} + +function handleForwardedClientIpHeaderPaste(event: ClipboardEvent) { + const text = event.clipboardData?.getData("text") || ""; + if (!text.trim()) { + return; + } + event.preventDefault(); + + let error: ForwardedClientIpHeaderResult | undefined; + for (const token of text.split(/[,,;\r\n]+/)) { + if (!token.trim()) { + continue; + } + const result = addForwardedClientIpHeader(token); + if (result === "invalid" || result === "full") { + error = result; + } + } + if (error) { + showForwardedClientIpHeaderError(error); + } +} + // Quota notify email helpers const addQuotaNotifyEmail = () => { if (!form.account_quota_notify_emails) { @@ -9354,6 +9553,10 @@ async function loadSettings() { normalizeRegistrationEmailSuffixDomains( settings.registration_email_suffix_whitelist, ); + form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders( + settings.forwarded_client_ip_headers, + ); + forwardedClientIpHeaderDraft.value = ""; tablePageSizeOptionsInput.value = formatTablePageSizeOptions( Array.isArray(settings.table_page_size_options) ? settings.table_page_size_options @@ -9595,6 +9798,9 @@ async function saveSettings() { form.login_agreement_mode = form.login_agreement_mode === "checkbox" ? "checkbox" : "modal"; form.login_agreement_documents = normalizedLoginAgreementDocuments; + form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders( + form.forwarded_client_ip_headers, + ); const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings( form.default_subscriptions, @@ -9728,6 +9934,7 @@ async function saveSettings() { turnstile_site_key: form.turnstile_site_key, turnstile_secret_key: form.turnstile_secret_key || undefined, api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip, + forwarded_client_ip_headers: form.forwarded_client_ip_headers, linuxdo_connect_enabled: form.linuxdo_connect_enabled, linuxdo_connect_client_id: form.linuxdo_connect_client_id, linuxdo_connect_client_secret: @@ -9993,6 +10200,10 @@ async function saveSettings() { normalizeRegistrationEmailSuffixDomains( updated.registration_email_suffix_whitelist, ); + form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders( + updated.forwarded_client_ip_headers, + ); + forwardedClientIpHeaderDraft.value = ""; tablePageSizeOptionsInput.value = formatTablePageSizeOptions( Array.isArray(updated.table_page_size_options) ? updated.table_page_size_options diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 8b7ffb301c..e913a1249a 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -368,6 +368,7 @@ const baseSettingsResponse = { turnstile_site_key: "", turnstile_secret_key_configured: false, api_key_acl_trust_forwarded_ip: true, + forwarded_client_ip_headers: [], linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", linuxdo_connect_client_secret_configured: false, @@ -653,10 +654,11 @@ describe("admin SettingsView payment visible method controls", () => { expect(wrapper.text()).not.toContain("支付来源"); }); - it("loads and saves the forwarded client IP takeover switch", async () => { + it("loads, edits, validates, and saves forwarded client-IP headers", async () => { getSettings.mockResolvedValueOnce({ ...baseSettingsResponse, api_key_acl_trust_forwarded_ip: false, + forwarded_client_ip_headers: ["cf-connecting-ip", "X-Real-IP"], }); const wrapper = mountView(); @@ -669,14 +671,43 @@ describe("admin SettingsView payment visible method controls", () => { expect(card).toBeDefined(); const toggle = card!.get('input[type="checkbox"]'); expect((toggle.element as HTMLInputElement).checked).toBe(false); + expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false); await toggle.setValue(true); + expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(2); + expect(card!.text()).toContain("Cf-Connecting-Ip"); + expect(card!.text()).toContain("X-Real-Ip"); + showError.mockClear(); + + const input = card!.get('[data-testid="forwarded-client-ip-headers-input"]'); + await input.setValue("x-client-ip"); + await input.trigger("keydown", { key: "Enter" }); + await input.setValue("X-CLIENT-IP"); + await input.trigger("keydown", { key: "Enter" }); + await input.setValue("invalid header"); + await input.trigger("keydown", { key: "Enter" }); + expect(showError).toHaveBeenCalledTimes(1); + expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(3); + + const realIpTag = card! + .findAll('[data-testid="forwarded-client-ip-header-tag"]') + .find((tag) => tag.text().includes("X-Real-Ip")); + expect(realIpTag).toBeDefined(); + await realIpTag!.get("button").trigger("click"); + expect(card!.text()).not.toContain("X-Real-Ip"); + + await toggle.setValue(false); + expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false); + await toggle.setValue(true); + expect(card!.text()).toContain("X-Client-Ip"); + await wrapper.find("form").trigger("submit.prevent"); await flushPromises(); expect(updateSettings).toHaveBeenCalledWith( expect.objectContaining({ api_key_acl_trust_forwarded_ip: true, + forwarded_client_ip_headers: ["Cf-Connecting-Ip", "X-Client-Ip"], }), ); });