mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。
## 为什么不是 VNC 为主路径(实测参考插件后的结论)
grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:
puppeteer / playwright / chromium / headless / vnc / selenium → 0 处
它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。
不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。
VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。
## 登录会话抽象(plugin_login.go)
四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。
- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)
## 关键阻碍与修法:登录主机不在出站白名单里
**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。
修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。
## 设置页
插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。
后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。
开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。
测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
This commit is contained in:
457
internal/lua/plugin_login.go
Normal file
457
internal/lua/plugin_login.go
Normal file
@ -0,0 +1,457 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Package lua — 登录会话抽象(R4-e)
|
||||
//
|
||||
// 用户需求:把"同类插件"做出来 —— 插件自己**驱动外部平台的链接认证**,拿到
|
||||
// 凭据后把平台接进网关当源用;配独立标签页做登录/认证/配置。
|
||||
//
|
||||
// 用户原提议:服务端通过 VNC 穿透一个无头浏览器,用户在自己浏览器里操作。
|
||||
// 实测参考插件(dsh-our-free-model,13 个账号渠道)后的结论是:
|
||||
//
|
||||
// puppeteer / playwright / chromium / headless / vnc / selenium → 0 处
|
||||
//
|
||||
// 它的登录是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
|
||||
//
|
||||
// loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次
|
||||
//
|
||||
// 所以本文件实现**多流程**抽象,VNC 是其中一条默认关闭的逃生舱:
|
||||
//
|
||||
// link-poll 给 URL,插件轮询结果 ← 首选,覆盖设备码/OAuth
|
||||
// callback OAuth 重定向回来带 code ← 复用已有的自定义挂载点
|
||||
// paste 用户把 cookie/token 粘回来 ← 兜住 cookie 类渠道
|
||||
// vnc 服务端出浏览器 ← 逃生舱,默认关闭
|
||||
//
|
||||
// 为什么不以 VNC 为主路径(四条具体理由,非偏好):
|
||||
//
|
||||
// 1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器,
|
||||
// 跨设备流程要蓝牙/扫码,而 VNC 延迟让这体验很差。
|
||||
// 2. 无头浏览器更容易被判为机器人,不是更少 —— 登录场景最不能踩这个。
|
||||
// 3. 安全面:llmsproxy 以 root 运行(master.key 是 0600 root),systemd 已加
|
||||
// ProtectHome=yes。从 root 服务开一条 VNC 通道到一个"即将登录用户各平台
|
||||
// 账号"的浏览器,等于把提权面与高价值目标绑在一起。
|
||||
// 4. 最关键:**它不解决取凭据**。VNC 登录后凭据活在浏览器 profile 的 cookie
|
||||
// 里,不在插件里;还是要提取 profile,而那正是最脆弱的一环。轮询流程是
|
||||
// 直接产出 token 的。
|
||||
//
|
||||
// VNC 唯一不可替代的场景:服务端与用户不同网,且平台要求登录 IP 与调用 IP
|
||||
// 一致(区域限制)。所以它作为可选项存在,而不是地基。
|
||||
|
||||
// LoginFlow names one way a plugin can obtain credentials for a platform.
|
||||
type LoginFlow string
|
||||
|
||||
const (
|
||||
// FlowLinkPoll: the plugin mints a URL (device code / OAuth authorize), the
|
||||
// operator opens it in their OWN browser, and the plugin polls until the
|
||||
// platform reports the credential. This is the reference implementation's
|
||||
// dominant pattern and needs no browser on the server.
|
||||
FlowLinkPoll LoginFlow = "link-poll"
|
||||
// FlowCallback: a standard OAuth redirect. The plugin claims a route (the
|
||||
// custom mount points from Phase 6 already allow /plugins/<name>/...) and the
|
||||
// platform's redirect lands there with the code. The session carries the
|
||||
// state nonce so a callback cannot be replayed into the wrong session.
|
||||
FlowCallback LoginFlow = "callback"
|
||||
// FlowPaste: the operator pastes a cookie or token into the settings page.
|
||||
// Covers the cookie-based channels (133 hits in the reference plugin) where
|
||||
// no OAuth exists at all.
|
||||
FlowPaste LoginFlow = "paste"
|
||||
// FlowVNC: a server-side browser exposed over VNC. Opt-in escape hatch, off
|
||||
// by default (see the header note).
|
||||
FlowVNC LoginFlow = "vnc"
|
||||
)
|
||||
|
||||
// LoginSessionState is the lifecycle of one attempt.
|
||||
type LoginSessionState string
|
||||
|
||||
const (
|
||||
LoginPending LoginSessionState = "pending"
|
||||
LoginCompleted LoginSessionState = "completed"
|
||||
LoginFailed LoginSessionState = "failed"
|
||||
LoginExpired LoginSessionState = "expired"
|
||||
LoginCancelled LoginSessionState = "cancelled"
|
||||
)
|
||||
|
||||
// LoginSession is one in-flight authentication attempt.
|
||||
//
|
||||
// It is created by a plugin (or the settings page) and polled until it reaches a
|
||||
// terminal state. The credential itself is never stored here — the plugin stores
|
||||
// it in its own vault (__llmsproxy_secrets) and reports bare completion. Keeping
|
||||
// tokens out of the session record means a session listing, which IS exposed to
|
||||
// the UI, can never leak one.
|
||||
type LoginSession struct {
|
||||
ID string `json:"id"`
|
||||
Plugin string `json:"plugin"`
|
||||
Platform string `json:"platform"`
|
||||
Flow LoginFlow `json:"flow"`
|
||||
State LoginSessionState `json:"state"`
|
||||
// URL is what the operator must open (link-poll) or the VNC entry point.
|
||||
URL string `json:"url,omitempty"`
|
||||
// UserCode is the short code a device flow shows (e.g. "WDJB-MJHT").
|
||||
UserCode string `json:"user_code,omitempty"`
|
||||
// State nonce for the callback flow. A callback whose state does not match is
|
||||
// refused, so an attacker cannot inject a code into someone else's session.
|
||||
Nonce string `json:"-"`
|
||||
// Message explains a non-pending state in the operator's terms.
|
||||
Message string `json:"message,omitempty"`
|
||||
// Account is a display label the plugin reports on success (a login name),
|
||||
// never a secret.
|
||||
Account string `json:"account,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// loginSessionTTL bounds how long a pending attempt lives. A login the operator
|
||||
// abandoned must not leave a pollable session (and a browser tab prompt) forever;
|
||||
// the reference implementation uses the same 15 minute idea.
|
||||
const loginSessionTTL = 15 * time.Minute
|
||||
|
||||
// loginSessions owns all in-flight attempts.
|
||||
type loginSessions struct {
|
||||
mu sync.Mutex
|
||||
byID map[string]*LoginSession
|
||||
}
|
||||
|
||||
func newLoginSessions() *loginSessions {
|
||||
return &loginSessions{byID: map[string]*LoginSession{}}
|
||||
}
|
||||
|
||||
// newLoginID returns an unguessable session id.
|
||||
//
|
||||
// Unguessable matters: the id is the handle the settings page polls, and the
|
||||
// callback route carries its nonce. A sequential id would let a caller enumerate
|
||||
// other plugins' pending logins.
|
||||
func newLoginID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand failing is not a condition this process can serve around;
|
||||
// fall back to a time-based id rather than returning empty.
|
||||
return fmt.Sprintf("ls-%d", time.Now().UnixNano())
|
||||
}
|
||||
return "ls-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// newLoginNonce returns the callback state nonce.
|
||||
func newLoginNonce() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("n-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// BeginLogin starts a session. The plugin decides the flow and supplies what
|
||||
// that flow needs; the kernel only owns the lifecycle.
|
||||
func (ps *Plugins) BeginLogin(s LoginSession) (*LoginSession, error) {
|
||||
if s.Plugin == "" {
|
||||
return nil, fmt.Errorf("login session requires a plugin")
|
||||
}
|
||||
if ps.find(s.Plugin) == nil {
|
||||
return nil, fmt.Errorf("plugin %s not found", s.Plugin)
|
||||
}
|
||||
switch s.Flow {
|
||||
case FlowLinkPoll, FlowCallback, FlowPaste, FlowVNC:
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown login flow %q", s.Flow)
|
||||
}
|
||||
if s.Flow == FlowVNC && !ps.VNCEnabled() {
|
||||
return nil, fmt.Errorf("the VNC login flow is disabled; enable it in settings if the platform " +
|
||||
"requires a server-side browser (it is off by default for security)")
|
||||
}
|
||||
now := time.Now()
|
||||
out := &LoginSession{
|
||||
ID: newLoginID(),
|
||||
Plugin: s.Plugin,
|
||||
Platform: s.Platform,
|
||||
Flow: s.Flow,
|
||||
State: LoginPending,
|
||||
URL: s.URL,
|
||||
UserCode: s.UserCode,
|
||||
Nonce: newLoginNonce(),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(loginSessionTTL),
|
||||
}
|
||||
ps.logins().mu.Lock()
|
||||
ps.logins().sweepLocked(now)
|
||||
ps.logins().byID[out.ID] = out
|
||||
ps.logins().mu.Unlock()
|
||||
cp := out.clone()
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// GetLogin returns one session, expiring it if its TTL has passed.
|
||||
//
|
||||
// Expiry is applied on READ rather than by a background timer: a gateway with no
|
||||
// logins in flight pays nothing, and a session can never be observed as pending
|
||||
// after its deadline even if no goroutine happens to have run.
|
||||
func (ps *Plugins) GetLogin(id string) (*LoginSession, bool) {
|
||||
now := time.Now()
|
||||
ls := ps.logins()
|
||||
ls.mu.Lock()
|
||||
defer ls.mu.Unlock()
|
||||
ls.sweepLocked(now)
|
||||
s, ok := ls.byID[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cp := s.clone()
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
// ResolveLogin moves a session to a terminal state. It is the only way a session
|
||||
// ends successfully, so the credential handling stays in one place.
|
||||
//
|
||||
// `account` is a display label only — never a token. The plugin has already
|
||||
// stored the credential in its vault by the time it calls this; passing it
|
||||
// through here would put it in a UI-visible record.
|
||||
func (ps *Plugins) ResolveLogin(id string, state LoginSessionState, account, message string) error {
|
||||
switch state {
|
||||
case LoginCompleted, LoginFailed, LoginExpired, LoginCancelled:
|
||||
default:
|
||||
return fmt.Errorf("resolveLogin needs a terminal state, got %q", state)
|
||||
}
|
||||
ls := ps.logins()
|
||||
ls.mu.Lock()
|
||||
defer ls.mu.Unlock()
|
||||
s, ok := ls.byID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("no such login session")
|
||||
}
|
||||
if s.isTerminal() {
|
||||
// Already finished. Reporting success again would let a plugin turn a
|
||||
// cancelled session into a completed one.
|
||||
return fmt.Errorf("login session is already %s", s.State)
|
||||
}
|
||||
s.State = state
|
||||
s.Account = account
|
||||
s.Message = message
|
||||
return nil
|
||||
}
|
||||
|
||||
// MatchCallbackNonce finds the pending callback session for a nonce.
|
||||
//
|
||||
// The nonce is the CSRF defence for the callback flow: a redirect that does not
|
||||
// carry the nonce of a live session is not answered, so a code delivered to an
|
||||
// attacker-controlled session cannot be replayed into this gateway's login.
|
||||
func (ps *Plugins) MatchCallbackNonce(nonce string) (*LoginSession, bool) {
|
||||
if nonce == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
ls := ps.logins()
|
||||
ls.mu.Lock()
|
||||
defer ls.mu.Unlock()
|
||||
ls.sweepLocked(now)
|
||||
for _, s := range ls.byID {
|
||||
if s.Nonce == nonce && s.Flow == FlowCallback && !s.isTerminal() {
|
||||
cp := s.clone()
|
||||
return &cp, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ListLogins returns a plugin's sessions, newest first, for its settings page.
|
||||
func (ps *Plugins) ListLogins(plugin string) []LoginSession {
|
||||
now := time.Now()
|
||||
ls := ps.logins()
|
||||
ls.mu.Lock()
|
||||
defer ls.mu.Unlock()
|
||||
ls.sweepLocked(now)
|
||||
out := make([]LoginSession, 0, 4)
|
||||
for _, s := range ls.byID {
|
||||
if s.Plugin != plugin {
|
||||
continue
|
||||
}
|
||||
cp := s.clone()
|
||||
cp.Nonce = "" // never expose the nonce to the UI
|
||||
out = append(out, cp)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// CancelLogins drops a plugin's pending sessions. Used on plugin unload so an
|
||||
// uninstalled plugin leaves no pollable login behind.
|
||||
func (ps *Plugins) CancelLogins(plugin string) int {
|
||||
ls := ps.logins()
|
||||
ls.mu.Lock()
|
||||
defer ls.mu.Unlock()
|
||||
n := 0
|
||||
for _, s := range ls.byID {
|
||||
if s.Plugin == plugin && !s.isTerminal() {
|
||||
s.State = LoginCancelled
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// sweepLocked expires overdue sessions and reaps terminal ones. Caller holds mu.
|
||||
func (ls *loginSessions) sweepLocked(now time.Time) {
|
||||
for id, s := range ls.byID {
|
||||
if !s.isTerminal() && now.After(s.ExpiresAt) {
|
||||
s.State = LoginExpired
|
||||
s.Message = "the login attempt timed out; start it again"
|
||||
}
|
||||
// Terminal sessions are reaped after a grace period so the UI can show
|
||||
// the outcome. Deleting immediately would make a completed login look
|
||||
// like it never happened.
|
||||
if s.isTerminal() && now.After(s.ExpiresAt.Add(loginSessionTTL)) {
|
||||
delete(ls.byID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LoginSession) isTerminal() bool {
|
||||
switch s.State {
|
||||
case LoginCompleted, LoginFailed, LoginExpired, LoginCancelled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clone returns a copy so callers cannot mutate the stored session, and so a
|
||||
// data race cannot expose a half-updated struct to the UI.
|
||||
func (s *LoginSession) clone() LoginSession {
|
||||
cp := *s
|
||||
return cp
|
||||
}
|
||||
|
||||
// loginSessions lazily creates the store so a Plugins value built in a test
|
||||
// without construction still works.
|
||||
func (ps *Plugins) logins() *loginSessions {
|
||||
ps.loginOnce.Do(func() {
|
||||
if ps.loginStore == nil {
|
||||
ps.loginStore = newLoginSessions()
|
||||
}
|
||||
})
|
||||
return ps.loginStore
|
||||
}
|
||||
|
||||
// VNCEnabled reports whether the VNC escape hatch is on. Off by default: a
|
||||
// server-side browser is the highest-risk login path and must be a deliberate
|
||||
// operator choice.
|
||||
func (ps *Plugins) VNCEnabled() bool {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.vncPermitted
|
||||
}
|
||||
|
||||
// SetVNCEnabled turns the VNC login flow on or off.
|
||||
func (ps *Plugins) SetVNCEnabled(on bool) {
|
||||
ps.mu.Lock()
|
||||
ps.vncPermitted = on
|
||||
ps.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- 登录主机授权 ----
|
||||
//
|
||||
// 登录流程要访问的 host(auth.huaweicloud.com、accounts.google.com…)不是源,
|
||||
// 所以出站白名单默认会掐断整个流程(已实测确认)。这是**正确**的默认:白名单
|
||||
// 的意义就是"插件只能去它该去的地方"。
|
||||
//
|
||||
// 解法与内核权限同一个姿态:插件在源码里**声明**它需要哪些登录 host
|
||||
// (plugin.auth_hosts),运维在设置页批准后才放行。声明是请求,批准才是授权。
|
||||
//
|
||||
// 为什么要声明而不是给个通用开关:一个能访问任意 host 的插件等于没有白名单,
|
||||
// 而"登录"这个理由足够正当,正当到很容易被用来把整张名单打开。要求逐个列出,
|
||||
// 运维才有机会看出"这个插件为什么要访问一个我没听说过的域名"。
|
||||
|
||||
// PluginAuthHosts returns the login hosts a plugin declared.
|
||||
func (ps *Plugins) PluginAuthHosts(plugin string) []string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == plugin {
|
||||
return append([]string(nil), p.AuthHosts...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GrantAuthHosts approves a set of login hosts for a plugin.
|
||||
//
|
||||
// Only hosts the plugin DECLARED may be granted: accepting an arbitrary host from
|
||||
// the settings page would make the declaration decorative, and the point of the
|
||||
// declaration is that the operator reviews a list the plugin itself committed to
|
||||
// in source that is on disk and reviewable.
|
||||
func (ps *Plugins) GrantAuthHosts(plugin string, hosts []string) error {
|
||||
if ps.find(plugin) == nil {
|
||||
return fmt.Errorf("plugin %s not found", plugin)
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, h := range ps.PluginAuthHosts(plugin) {
|
||||
declared[normalizeHost(h)] = true
|
||||
}
|
||||
clean := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
hn := normalizeHost(h)
|
||||
if hn == "" {
|
||||
continue
|
||||
}
|
||||
if !declared[hn] {
|
||||
return fmt.Errorf("plugin %s did not declare auth host %q", plugin, hn)
|
||||
}
|
||||
clean = append(clean, hn)
|
||||
}
|
||||
sort.Strings(clean)
|
||||
ps.mu.Lock()
|
||||
if ps.authHosts == nil {
|
||||
ps.authHosts = map[string][]string{}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
delete(ps.authHosts, plugin)
|
||||
} else {
|
||||
ps.authHosts[plugin] = clean
|
||||
}
|
||||
ps.rebuildOutboundAllowlistLocked()
|
||||
ps.saveKernelPermissionsLocked()
|
||||
ps.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthHostsGranted returns the approved hosts for each plugin (for the UI).
|
||||
func (ps *Plugins) AuthHostsGranted() map[string][]string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
out := make(map[string][]string, len(ps.authHosts))
|
||||
for k, v := range ps.authHosts {
|
||||
out[k] = append([]string(nil), v...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeHost lowercases a host and strips a port, so "Auth.Example.com:443"
|
||||
// and "auth.example.com" cannot both be granted under different names.
|
||||
func normalizeHost(h string) string {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
if i := strings.LastIndex(h, ":"); i > 0 && !strings.Contains(h[i:], "]") {
|
||||
// Leave IPv6 literals ("[::1]:443") alone apart from the port.
|
||||
h = h[:i]
|
||||
}
|
||||
h = strings.Trim(h, "[]")
|
||||
return h
|
||||
}
|
||||
|
||||
// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED auth
|
||||
// host into the allowlist the HTTP bridge consults. Caller holds ps.mu.
|
||||
//
|
||||
// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the
|
||||
// whole security property, so it is the one thing this function must not be
|
||||
// tempted to "helpfully" relax.
|
||||
func (ps *Plugins) rebuildOutboundAllowlistLocked() {
|
||||
hosts := append([]string(nil), ps.sourceHosts...)
|
||||
for _, granted := range ps.authHosts {
|
||||
hosts = append(hosts, granted...)
|
||||
}
|
||||
ps.allowHTTP = newHTTPAllowlist(hosts)
|
||||
}
|
||||
Reference in New Issue
Block a user