fix: stabilize console scrolling and protect administrator login
This commit is contained in:
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -64,6 +67,12 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||
writeConsoleJSON(w, http.StatusOK, map[string]any{"authenticated": true})
|
||||
})
|
||||
mux.HandleFunc("POST /console/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := http.NewCrossOriginProtection().Check(r); err != nil {
|
||||
writeConsoleJSON(w, http.StatusForbidden, map[string]any{"error": map[string]string{"code": "PERMISSION_DENIED", "message": "不允许跨站登录请求"}})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
|
||||
|
||||
if consoleAuth == nil {
|
||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "console authentication is not configured"}})
|
||||
return
|
||||
@@ -78,10 +87,23 @@ func RegisterConsole(server *http.Server, authStores ...consoleauth.Store) {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), remoteAddress(r))
|
||||
login, err := consoleAuth.Login(ctx, in.Username, in.Password, r.UserAgent(), loginRemoteAddress(r, consoleAuth))
|
||||
if err != nil {
|
||||
// 账号不存在、密码错误、账号停用和绑定凭据失效统一返回,避免账号枚举。
|
||||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||
var blocked *consoleauth.LoginBlocked
|
||||
if errors.As(err, &blocked) {
|
||||
seconds := max(1, int(math.Ceil(blocked.RetryAfter.Seconds())))
|
||||
code, message := "LOGIN_RATE_LIMITED", "登录尝试过于频繁,请稍后重试"
|
||||
if blocked.Locked {
|
||||
code, message = "LOGIN_LOCKED", "连续登录失败,已临时锁定,请稍后重试"
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(seconds))
|
||||
writeConsoleJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"code": code, "message": message, "retry_after_seconds": seconds}})
|
||||
} else if errors.Is(err, fault.Unauthorized) {
|
||||
writeConsoleJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]string{"code": "UNAUTHENTICATED", "message": "用户名或密码错误"}})
|
||||
} else {
|
||||
// 存储或审计不可用时不发 Cookie,不将基础设施故障伪装成密码错误。
|
||||
writeConsoleJSON(w, http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "UNAVAILABLE", "message": "登录服务暂不可用,请稍后重试"}})
|
||||
}
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: consoleSessionCookie, Value: login.Token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Expires: login.ExpiresAt, MaxAge: int(time.Until(login.ExpiresAt).Seconds())})
|
||||
|
||||
@@ -12,6 +12,26 @@
|
||||
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 2800);
|
||||
}
|
||||
let loginAuditCursor = '', loginAuditPage = 1, loginAuditLoading = false;
|
||||
async function loadLoginAudit(first = false) {
|
||||
if (loginAuditLoading) return;
|
||||
loginAuditLoading = true;
|
||||
$('login-audit-first').disabled = true; $('login-audit-next').disabled = true;
|
||||
text('login-audit-status', '正在读取…');
|
||||
try {
|
||||
const cursor = first ? '' : loginAuditCursor;
|
||||
const page = extractData(await request(`/console/auth/audit${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`));
|
||||
const labels = { SUCCESS: '登录成功', INVALID: '账号或密码错误', LOCKED: '临时锁定', ACCOUNT_LIMIT: '账号限流', IP_LIMIT: 'IP 限流', PENDING: '校验中', ERROR: '未完成 / 服务异常' };
|
||||
$('login-audit-rows').innerHTML = (page.items || []).map(row => `<tr><td>${escapeHTML(formatTime(row.created_at))}</td><td>${escapeHTML(row.username)}</td><td>${escapeHTML(row.remote_ip)}</td><td>${escapeHTML(labels[row.outcome] || row.outcome)}</td><td>${escapeHTML(formatTime(row.retry_at))}</td></tr>`).join('');
|
||||
loginAuditPage = first ? 1 : loginAuditPage + 1; loginAuditCursor = page.next || '';
|
||||
text('login-audit-page', `第 ${loginAuditPage} 页`); text('login-audit-status', page.items?.length ? '' : '暂无登录记录');
|
||||
} catch (error) { text('login-audit-status', `读取失败:${error.message}`); }
|
||||
finally { loginAuditLoading = false; $('login-audit-first').disabled = false; $('login-audit-next').disabled = !loginAuditCursor; }
|
||||
}
|
||||
$('login-audit-open').addEventListener('click', () => { $('login-audit-dialog').showModal(); loadLoginAudit(true); });
|
||||
$('login-audit-close').addEventListener('click', () => $('login-audit-dialog').close());
|
||||
$('login-audit-first').addEventListener('click', () => loadLoginAudit(true));
|
||||
$('login-audit-next').addEventListener('click', () => loadLoginAudit());
|
||||
function formatTime(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value);
|
||||
@@ -137,6 +157,7 @@
|
||||
appendScopeOptions($('scope-application'), scopeUI.applications, scope.entry.namespace_id, '', 'application');
|
||||
appendScopeOptions($('scope-queue'), queues, state.queue, scopeUI.nextQueue, 'queue');
|
||||
$('scope-application').disabled = !scope.can_switch;
|
||||
$('login-audit-open').hidden = !scope.can_switch;
|
||||
$('scope-queue').disabled = !queues.length;
|
||||
if (scope.can_switch) {
|
||||
try { await loadScopeApplications(); } catch (error) { text('scope-error', '应用目录读取失败:' + error.message); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="console-document">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -42,6 +42,7 @@
|
||||
<div class="topbar-actions"><label class="auto-refresh"><input type="checkbox" id="auto-refresh" checked>自动刷新</label>
|
||||
<span class="live-chip"><i></i><span id="live-label">本地环境</span></span>
|
||||
<span class="last-updated" id="last-updated" aria-live="polite">尚未刷新</span>
|
||||
<button class="secondary-button compact" id="login-audit-open" hidden>登录审计</button>
|
||||
<button class="icon-button" id="logout-button" title="退出登录" aria-label="退出登录">↪</button>
|
||||
<button class="icon-button" id="refresh-button" title="刷新数据" aria-label="刷新数据">↻</button>
|
||||
</div>
|
||||
@@ -357,6 +358,14 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div>
|
||||
<dialog id="login-audit-dialog" class="access-dialog login-audit-dialog" aria-labelledby="login-audit-title">
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="login-audit-close" aria-label="关闭登录审计">×</button></div>
|
||||
<h2 id="login-audit-title">登录审计</h2>
|
||||
<p class="panel-description">记录登录成功、失败、临时锁定与限流。每页 10 条,时间为当前时区。</p>
|
||||
<p id="login-audit-status" role="status"></p>
|
||||
<div class="table-wrap"><table><thead><tr><th>时间</th><th>账号</th><th>来源 IP</th><th>结果</th><th>可重试时间</th></tr></thead><tbody id="login-audit-rows"></tbody></table></div>
|
||||
<div class="audit-pagination"><button class="secondary-button compact" id="login-audit-first">最新记录</button><span id="login-audit-page">第 1 页</span><button class="secondary-button compact" id="login-audit-next" disabled>下一页 →</button></div>
|
||||
</dialog>
|
||||
<dialog id="access-dialog" class="access-dialog">
|
||||
<div class="access-dialog-top"><button type="button" class="icon-button" id="access-close" aria-label="关闭应用接入">×</button></div>
|
||||
<h2 id="access-form-title">新建应用</h2><p class="panel-description" id="access-form-note">为应用创建独立的环境、队列与调用凭据。</p>
|
||||
|
||||
@@ -69,7 +69,31 @@
|
||||
// 初次访问时 URL 片段不会发给服务端,浏览器会把它保留在跳转后的登录地址上。
|
||||
if (!returnTo.includes('#') && /^#\/(overview|tasks|schedules|workflows|subscriptions|retention|archives|applications)$/.test(location.hash)) returnTo += location.hash;
|
||||
fetch('/console/auth/session', { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' }).then(response => response.json()).then(body => { if (body.authenticated) location.replace(returnTo); }).catch(() => {});
|
||||
form.addEventListener('submit', async event => { event.preventDefault(); error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…'; try { const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body?.error?.message || '登录失败,请检查用户名和密码'); location.replace(returnTo); } catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); } finally { submit.disabled = false; submit.textContent = '登录 Orbit →'; } });
|
||||
let retryUntil = 0, retryTimer;
|
||||
function showRetry() {
|
||||
const remaining = Math.max(0, Math.ceil((retryUntil - Date.now()) / 1000));
|
||||
submit.disabled = remaining > 0;
|
||||
submit.textContent = remaining ? `请等待 ${Math.floor(remaining / 60)}分 ${remaining % 60}秒` : '登录 Orbit →';
|
||||
if (!remaining) clearInterval(retryTimer);
|
||||
}
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (Date.now() < retryUntil) return;
|
||||
error.textContent = ''; submit.disabled = true; submit.textContent = '正在登录…';
|
||||
try {
|
||||
const response = await fetch('/console/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: username.value.trim(), password: password.value }) });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const seconds = Number(body?.error?.retry_after_seconds);
|
||||
if (response.status === 429 && Number.isFinite(seconds) && seconds > 0) {
|
||||
retryUntil = Date.now() + seconds * 1000;
|
||||
clearInterval(retryTimer); retryTimer = setInterval(showRetry, 1000);
|
||||
}
|
||||
throw new Error(body?.error?.message || '登录失败,请稍后重试');
|
||||
}
|
||||
location.replace(returnTo);
|
||||
} catch (loginError) { error.textContent = loginError.message || '登录失败,请稍后重试'; password.select(); }
|
||||
finally { showRetry(); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -136,14 +136,18 @@ input:focus-visible,textarea:focus-visible { outline:none; }
|
||||
@media(max-width:1200px){.scope-field{flex:1;min-width:160px}.scope-note{margin-left:0}}
|
||||
@media(max-width:920px){.application-scope{gap:12px}.scope-note{flex-basis:100%;margin-top:-4px}.list-filters{grid-template-columns:145px minmax(160px,1fr)}.filter-more{grid-column:1;grid-row:2}.filter-actions{grid-column:2;grid-row:2}.login-brand{padding:40px}.login-intro h1{font-size:40px}}
|
||||
@media(max-width:640px){.application-scope{padding:14px;gap:12px}.scope-field{flex:1 1 100%;min-width:0}.scope-note{display:none}.scope-field>span{margin-bottom:5px}.list-filters{padding:16px;grid-template-columns:1fr 1fr;gap:14px}.list-filters>label{grid-column:1/-1;min-width:0!important}.filter-more{grid-column:1;grid-row:3}.filter-actions{grid-column:2;grid-row:3;gap:7px}.filter-actions button{flex:1;padding:0 11px}.filter-dates{grid-row:4;gap:14px}.filter-dates label{flex:1 1 100%;min-width:0}.filter-dates small{padding:0}.list-pagination{padding:14px 10px;gap:12px}.list-pagination>div{gap:6px}.list-pagination .select-trigger{width:65px;height:32px}.login-brand{padding:28px}.login-brand>*{width:100%;max-width:380px}.login-intro{margin:30px auto 8px}.login-intro h1{font-size:28px}.login-card{width:100%}}
|
||||
/* 工作台只滚动内容区,导航和页头保持原位;避免整页滚动、回弹及滚动条切换造成错位。 */
|
||||
.console-body { height:100dvh; overflow:hidden; overscroll-behavior:none; }
|
||||
.console-body .app-shell { height:100dvh; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); }
|
||||
.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:contain; }
|
||||
.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; scrollbar-gutter:stable; scroll-padding-top:100px; }
|
||||
/* none 同时关闭边界回弹和滚动传递;contain 仍会让 macOS 上的菜单和内容弹动。
|
||||
根视口与各实际滚动容器分别设置,保留短屏导航、内容和弹层的正常滚动。 */
|
||||
.console-document, .console-body { height:100%; overflow:hidden; overscroll-behavior:none; }
|
||||
.console-body .app-shell { position:fixed; inset:0; height:100%; min-height:0; overflow:hidden; grid-template-rows:minmax(0,1fr); }
|
||||
.console-body .sidebar { position:relative; height:100%; min-height:0; overscroll-behavior:none; }
|
||||
.console-body .main-content { height:100%; min-height:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:none; scrollbar-gutter:stable; scroll-padding-top:100px; }
|
||||
.console-body .topbar { position:sticky; top:0; z-index:20; background:var(--bg); }
|
||||
@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:contain}}
|
||||
@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior-x:contain}}
|
||||
.console-body :is(.select-options,.table-wrap,.task-drawer,.access-dialog){overscroll-behavior:none}
|
||||
@media(min-width:921px){.access-list-panel{position:sticky;top:100px;max-height:calc(100dvh - 124px);overflow:auto;overscroll-behavior:none}}
|
||||
@media(max-width:640px){.console-body .app-shell{display:flex;flex-direction:column}.console-body .sidebar{height:auto;flex:0 0 auto;overflow:visible}.console-body .main-content{flex:1;scroll-padding-top:78px}.nav-list{overscroll-behavior:none}}
|
||||
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
||||
|
||||
@media(max-width:640px){.data-panel .table-wrap::before{content:"左右滑动查看完整表格 →";display:block;padding:0 15px 10px;color:var(--muted);font-size:10px}.retention-panel{max-width:none}}
|
||||
|
||||
.login-audit-dialog{width:min(960px,calc(100vw - 32px))}.login-audit-dialog .table-wrap{margin:20px 0}.login-audit-dialog th,.login-audit-dialog td{padding:12px;text-align:left;white-space:nowrap}.login-audit-dialog .audit-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
|
||||
@@ -44,6 +44,20 @@ func RegisterConsoleApplications(server *http.Server, consoleAuth consoleauth.St
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
if auditor, ok := store.(consoleauth.LoginAuditor); ok {
|
||||
mux.HandleFunc("GET /console/auth/audit", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := auditor.ListLoginAudit(r.Context(), token, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
writeConsoleJSON(w, 200, map[string]any{"data": out})
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("GET /console/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := check(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -2,8 +2,11 @@ package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,12 +17,13 @@ import (
|
||||
)
|
||||
|
||||
type fakeConsoleAuth struct {
|
||||
valid bool
|
||||
login consoleauth.LoginResult
|
||||
valid bool
|
||||
login consoleauth.LoginResult
|
||||
loginError error
|
||||
}
|
||||
|
||||
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
||||
return f.login, nil
|
||||
return f.login, f.loginError
|
||||
}
|
||||
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
||||
if !f.valid {
|
||||
@@ -82,3 +86,69 @@ func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) {
|
||||
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLoginRejectsWithRetryAndNoCookie(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"password", fault.Unauthorized, 401, "UNAUTHENTICATED"},
|
||||
{"locked", &consoleauth.LoginBlocked{Locked: true, RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_LOCKED"},
|
||||
{"rate", &consoleauth.LoginBlocked{RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_RATE_LIMITED"},
|
||||
{"storage", errors.New("private database error"), 503, "UNAVAILABLE"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{loginError: tc.err})
|
||||
req := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"password-password"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, req)
|
||||
if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.code) || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private database") {
|
||||
t.Fatalf("response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Code == 429 && w.Header().Get("Retry-After") != "2" {
|
||||
t.Fatal("missing rounded Retry-After")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type policyConsoleAuth struct {
|
||||
fakeConsoleAuth
|
||||
policy consoleauth.LoginPolicy
|
||||
}
|
||||
|
||||
func (s *policyConsoleAuth) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
||||
func TestLoginSourceOnlyTrustsConfiguredProxyChain(t *testing.T) {
|
||||
s := &policyConsoleAuth{policy: consoleauth.DefaultLoginPolicy()}
|
||||
s.policy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
||||
for _, tc := range []struct{ peer, forward, want string }{
|
||||
{"192.0.2.1:1234", "198.51.100.7", "192.0.2.1"},
|
||||
{"10.0.0.1:1234", "198.51.100.7, 10.0.0.2", "198.51.100.7"},
|
||||
{"10.0.0.1:1234", "203.0.113.99, 198.51.100.7", "198.51.100.7"},
|
||||
{"10.0.0.1:1234", "garbage, 10.0.0.2", "10.0.0.1"},
|
||||
{"[::1]:1234", "::ffff:192.0.2.1", "192.0.2.1"},
|
||||
} {
|
||||
r := httptest.NewRequest("POST", "/console/auth/login", nil)
|
||||
r.RemoteAddr = tc.peer
|
||||
r.Header.Set("X-Forwarded-For", tc.forward)
|
||||
r.Header.Set("X-Real-IP", "203.0.113.123")
|
||||
if got := loginRemoteAddress(r, s); got != tc.want {
|
||||
t.Fatalf("%s / %s: got %s", tc.peer, tc.forward, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLoginRejectsCrossSite(t *testing.T) {
|
||||
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
||||
r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Origin", "https://evil.invalid")
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(w, r)
|
||||
if w.Code != 403 || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("cross-site response: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
57
internal/transport/httpserver/console_login.go
Normal file
57
internal/transport/httpserver/console_login.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loginRemoteAddress 默认只信任 TCP 对端;仅受信反代可提供 X-Forwarded-For。
|
||||
// 从右往左剥离可信跳点,遇到首个非可信地址即停止;不接受任意客户端伪造的首项。
|
||||
func loginRemoteAddress(r *http.Request, store consoleauth.Store) string {
|
||||
peer, err := netip.ParseAddr(remoteAddress(r))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
peer = peer.Unmap()
|
||||
policyStore, ok := store.(interface {
|
||||
LoginPolicy() consoleauth.LoginPolicy
|
||||
})
|
||||
if !ok {
|
||||
return peer.String()
|
||||
}
|
||||
trusted := policyStore.LoginPolicy().TrustedProxies
|
||||
isTrusted := func(ip netip.Addr) bool {
|
||||
for _, n := range trusted {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !isTrusted(peer) {
|
||||
return peer.String()
|
||||
}
|
||||
raw := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
|
||||
if raw == "" || len(raw) > 2048 {
|
||||
return peer.String()
|
||||
}
|
||||
hops := strings.Split(raw, ",")
|
||||
if len(hops) > 32 {
|
||||
return peer.String()
|
||||
}
|
||||
addresses := make([]netip.Addr, len(hops))
|
||||
for i, h := range hops {
|
||||
a, e := netip.ParseAddr(strings.TrimSpace(h))
|
||||
if e != nil {
|
||||
return peer.String()
|
||||
}
|
||||
addresses[i] = a.Unmap()
|
||||
}
|
||||
current := peer
|
||||
for i := len(addresses) - 1; i >= 0 && isTrusted(current); i-- {
|
||||
current = addresses[i]
|
||||
}
|
||||
return current.String()
|
||||
}
|
||||
Reference in New Issue
Block a user