This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 46bf1d8970e002dff96b508ce3434ae0c18fb0ee
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 16:06:48 2026 +0800

    feat(origin): let browser extensions call the relay, and edit the 
host/origin lists in Settings
---
 conf/app.conf                 |  5 +++--
 conf/conf.go                  |  5 +++--
 object/setting.go             | 25 +++++++++++++++++++++++++
 routers/origin.go             | 39 ++++++++++++++++++++++++++++++++++-----
 web/src/locales/en/data.json  |  4 ++++
 web/src/locales/zh/data.json  |  4 ++++
 web/src/pages/SettingPage.tsx |  2 ++
 web/src/types.ts              |  5 +++++
 8 files changed, 80 insertions(+), 9 deletions(-)

diff --git a/conf/app.conf b/conf/app.conf
index 0f93c1e..55737b9 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -21,11 +21,12 @@ copyrequestbody = true
 ; other name that is not this machine's. Its own addresses, "localhost" and its
 ; hostname always work; a browser request for any other name is refused, so 
that
 ; a domain pointed at 127.0.0.1 cannot drive Gateway through the operator's
-; browser.
+; browser. Edited from the Settings page afterwards, like the settings above.
 allowedHosts =
 ; Browser origins allowed to call the API from another site, comma-separated 
and
 ; written in full, e.g. "https://app.example.com";. The web UI needs none of
-; these; a page of your own that calls the relay does.
+; these, and neither does a browser extension calling /v1; a page of your own
+; that calls the management API does.
 allowedOrigins =
 
 ; Database. XORM is used, so mysql, postgres, mssql and sqlite are all 
supported.
diff --git a/conf/conf.go b/conf/conf.go
index f6cc65b..a69e1cc 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -166,8 +166,9 @@ func GetAllowedHosts() []string {
 }
 
 // GetAllowedOrigins is the browser origins allowed to call the API from 
another
-// site. The web UI is served by Gateway itself, so it needs none of them: this
-// is for a page of your own that talks to the relay.
+// site. The web UI is served by Gateway itself, so it needs none of them, and
+// neither does a browser extension calling the relay: this is for a page of
+// your own that talks to the management API.
 func GetAllowedOrigins() []string {
        return getConfigList("allowedOrigins")
 }
diff --git a/object/setting.go b/object/setting.go
index e0b3edd..2965987 100644
--- a/object/setting.go
+++ b/object/setting.go
@@ -84,6 +84,12 @@ type Setting struct {
        // first start; clearing it on the Settings page issues a new one.
        RelayToken string `xorm:"varchar(200)" json:"relayToken"`
 
+       // AllowedHosts is the extra Host header names Gateway answers to, and
+       // AllowedOrigins the browser origins allowed to call it from another 
site.
+       // Both are comma-separated.
+       AllowedHosts   string `xorm:"varchar(1000)" json:"allowedHosts"`
+       AllowedOrigins string `xorm:"varchar(1000)" json:"allowedOrigins"`
+
        HttpProxy string `xorm:"varchar(200)" json:"httpProxy"`
 }
 
@@ -130,6 +136,9 @@ func SyncSettingToConf(setting *Setting) {
                "apiKeyEncryptionKey": setting.ApiKeyEncryptionKey,
                "relayToken":          setting.RelayToken,
 
+               "allowedHosts":   setting.AllowedHosts,
+               "allowedOrigins": setting.AllowedOrigins,
+
                "httpProxy": setting.HttpProxy,
        })
 }
@@ -249,6 +258,19 @@ func InitBuiltInSetting() {
                }
        }
 
+       // The origin filter used to read these from conf/app.conf alone, so an
+       // installation that listed a name there keeps it when the columns 
arrive.
+       if setting.AllowedHosts == "" && setting.AllowedOrigins == "" {
+               setting.AllowedHosts = 
conf.GetConfigStringUnquoted("allowedHosts")
+               setting.AllowedOrigins = 
conf.GetConfigStringUnquoted("allowedOrigins")
+               if setting.AllowedHosts != "" || setting.AllowedOrigins != "" {
+                       if _, err = ormer.Engine.ID(core.PK{setting.Owner, 
setting.Name}).
+                               Cols("allowed_hosts", 
"allowed_origins").Update(setting); err != nil {
+                               panic(err)
+                       }
+               }
+       }
+
        initCloudSyncSetting(setting)
 
        SyncSettingToConf(setting)
@@ -297,6 +319,9 @@ func newSettingFromConf() *Setting {
                ApiKeyEncryptionKey: 
conf.GetConfigStringUnquoted("apiKeyEncryptionKey"),
                RelayToken:          newRelayToken(),
 
+               AllowedHosts:   conf.GetConfigStringUnquoted("allowedHosts"),
+               AllowedOrigins: conf.GetConfigStringUnquoted("allowedOrigins"),
+
                HttpProxy: conf.GetConfigStringUnquoted("httpProxy"),
        }
 }
diff --git a/routers/origin.go b/routers/origin.go
index 20716aa..ce1696a 100644
--- a/routers/origin.go
+++ b/routers/origin.go
@@ -34,11 +34,13 @@ import (
 // Two checks, and a client that is not a browser — curl, an agent CLI — passes
 // both without sending anything. The Host has to name this machine, which the
 // attacker domain of a DNS rebinding does not, and a request carrying an 
Origin
-// has to come from Gateway's own pages or from an origin the operator listed.
+// has to come from Gateway's own pages, from an installed browser extension
+// calling the relay, or from an origin the operator listed on the Settings
+// page.
 func OriginFilter(ctx *context.Context) {
        host := ctx.Request.Host
        if !isAllowedHost(host) {
-               denyOrigin(ctx, fmt.Sprintf("%q is not a name this Gateway 
answers to; add it to \"allowedHosts\" in conf/app.conf to reach Gateway under 
that name",
+               denyOrigin(ctx, fmt.Sprintf("%q is not a name this Gateway 
answers to; add it to \"Allowed hosts\" on the Settings page to reach Gateway 
under that name",
                        hostnameOf(host)))
                return
        }
@@ -47,12 +49,12 @@ func OriginFilter(ctx *context.Context) {
        if origin == "" || sameOrigin(origin, host) {
                return
        }
-       if !isAllowedOrigin(origin) {
-               denyOrigin(ctx, fmt.Sprintf("%s is another site, and Gateway is 
not callable from one; add it to \"allowedOrigins\" in conf/app.conf to allow 
it", origin))
+       if !isAllowedOrigin(origin) && !isAllowedExtensionCall(ctx, origin) {
+               denyOrigin(ctx, fmt.Sprintf("%s is another site, and Gateway is 
not callable from one; add it to \"Allowed origins\" on the Settings page to 
allow it", origin))
                return
        }
 
-       // An origin the operator listed gets the CORS headers the browser 
needs to
+       // An origin that got through gets the CORS headers the browser needs to
        // hand the answer to it.
        writeCorsHeaders(ctx, origin)
        if isPreflight(ctx) {
@@ -103,6 +105,33 @@ func isOwnHostname(lower string) bool {
        return lower == own || strings.HasPrefix(lower, own+".")
 }
 
+// isAllowedExtensionCall lets a browser extension use the relay. A page cannot
+// forge an extension origin, so one does not come from another site at all: it
+// comes from software the operator installed, which reaches /v1 the way a 
local
+// agent does. The management API is not part of the deal — an extension has no
+// business editing providers — so an extension origin is listed like any other
+// to reach that.
+func isAllowedExtensionCall(ctx *context.Context, origin string) bool {
+       return isExtensionOrigin(origin) && isRelayPath(ctx.Request.URL.Path)
+}
+
+var extensionSchemes = []string{"moz-extension://", "chrome-extension://", 
"safari-web-extension://", "extension://"}
+
+func isExtensionOrigin(origin string) bool {
+       lower := strings.ToLower(origin)
+       for _, scheme := range extensionSchemes {
+               if strings.HasPrefix(lower, scheme) {
+                       return true
+               }
+       }
+       return false
+}
+
+// isRelayPath separates the LLM gateway from the management API.
+func isRelayPath(path string) bool {
+       return path == "/v1" || strings.HasPrefix(path, "/v1/") || 
strings.HasPrefix(path, "/v1beta/")
+}
+
 func isAllowedOrigin(origin string) bool {
        for _, allowed := range conf.GetAllowedOrigins() {
                if strings.EqualFold(strings.TrimSuffix(allowed, "/"), origin) {
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index a78a9b2..76b1c5d 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1091,6 +1091,10 @@
     "Agent state dir": "Agent state directory",
     "Agent state dir hint": "Patch manifests, config backups, ingest tokens 
and file-tail cursors.",
     "Agents": "Agents",
+    "Allowed hosts": "Allowed hosts",
+    "Allowed hosts hint": "Extra Host names Gateway answers to, 
comma-separated. Its own addresses, \"localhost\" and this machine's hostname 
always work; add a name only when a reverse proxy or another domain points 
here.",
+    "Allowed origins": "Allowed origins",
+    "Allowed origins hint": "Browser origins allowed to call the management 
API from another site, comma-separated and written in full, e.g. 
\"https://app.example.com\";. The web UI needs none of these, and neither does a 
browser extension calling /v1.",
     "Back up now": "Back up now",
     "Backups": "Backups",
     "Backups are off": "Backups are off",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index ca36f19..baf24df 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1163,6 +1163,10 @@
     "Agent state dir": "Agent 状态目录",
     "Agent state dir hint": "补丁清单、配置备份、上报令牌和文件读取位置。",
     "Agents": "Agent",
+    "Allowed hosts": "允许的主机名",
+    "Allowed hosts hint": "网关额外响应的 Host 名称,用逗号分隔。本机地址、\"localhost\" 
和本机主机名始终可用;仅当有反向代理或其他域名指向这里时才需要添加。",
+    "Allowed origins": "允许的来源",
+    "Allowed origins hint": "允许从其他站点调用管理 API 的浏览器来源,用逗号分隔并写全,例如 
\"https://app.example.com\"。网页控制台无需配置,浏览器扩展调用 /v1 也无需配置。",
     "Back up now": "立即备份",
     "Backups": "备份",
     "Backups are off": "自动备份已关闭",
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
index 7b36215..f9630b0 100644
--- a/web/src/pages/SettingPage.tsx
+++ b/web/src/pages/SettingPage.tsx
@@ -215,6 +215,8 @@ export default function SettingPage({account}: {account: 
Account}) {
 
       <Section id="network" columns={2} collapsible 
title={i18next.t("setting:Network")}>
         {textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"), 
i18next.t("setting:Outbound SOCKS5 proxy hint"))}
+        {textField("allowedHosts", i18next.t("setting:Allowed hosts"), 
i18next.t("setting:Allowed hosts hint"))}
+        {textField("allowedOrigins", i18next.t("setting:Allowed origins"), 
i18next.t("setting:Allowed origins hint"))}
       </Section>
 
       <div id="backups" className="scroll-mt-20">
diff --git a/web/src/types.ts b/web/src/types.ts
index 951065a..bda14f9 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -847,6 +847,11 @@ export interface Setting {
   apiKeyEncryptionKey: string;
   relayToken: string;
 
+  /** Comma-separated: extra Host names Gateway answers to, and the browser
+   *  origins allowed to call it from another site. */
+  allowedHosts: string;
+  allowedOrigins: string;
+
   httpProxy: string;
 
   backupMode: BackupMode;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to