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"],
}),
);
});