You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/03 06:37:08 UTC
(casbin-gateway) branch master updated: fix: refuse cross-origin and rebound-host requests
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
The following commit(s) were added to refs/heads/master by this push:
new 55b1ac5 fix: refuse cross-origin and rebound-host requests
55b1ac5 is described below
commit 55b1ac5305e0b4f66dabcd733be6e56206866dbb
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Thu Sep 3 14:36:59 2026 +0800
fix: refuse cross-origin and rebound-host requests
---
conf/app.conf | 12 +++++
conf/conf.go | 31 +++++++++++
main.go | 16 +++---
routers/origin.go | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 207 insertions(+), 8 deletions(-)
diff --git a/conf/app.conf b/conf/app.conf
index d7e189c..3ad31d9 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -21,6 +21,18 @@ runmode = dev
SessionOn = true
copyrequestbody = true
+; Names Gateway answers to, beside this host's own addresses, "localhost" and
+; this machine's hostname, which are always answered to. A browser request for
+; any other name is refused, so that a domain someone points at 127.0.0.1 cannot
+; drive Gateway through the browser of whoever is signed in here. List the name
+; here (comma-separated) when Gateway is reached through a reverse proxy or any
+; other name that is not this machine's.
+allowedHosts =
+; Browser origins allowed to call the API from another site, comma-separated and
+; written in full, e.g. "https://app.example.com". Gateway serves its own web UI,
+; which needs none of these; a page of your own that calls the relay does.
+allowedOrigins =
+
; Database. An empty "dataSourceName" means "./data/casbin-gateway.db"; "dbName" is
; unused by SQLite. XORM is used, so mysql, postgres, mssql and sqlite are all
; supported. To use MySQL instead, replace the two lines below with:
diff --git a/conf/conf.go b/conf/conf.go
index 356e67e..7505b0a 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -157,6 +157,37 @@ func IsHttpAddrLoopback() bool {
return ip != nil && ip.IsLoopback()
}
+// GetAllowedHosts is the extra Host header values the management UI and the
+// REST API answer to. This host's own addresses and names are always answered
+// to; a name is only listed here when Gateway is reached through one that is
+// not this machine's, such as a reverse proxy in front of it.
+func GetAllowedHosts() []string {
+ return getConfigList("allowedHosts")
+}
+
+// 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.
+func GetAllowedOrigins() []string {
+ return getConfigList("allowedOrigins")
+}
+
+// getConfigList reads a comma-separated setting.
+func getConfigList(key string) []string {
+ raw := strings.Trim(GetConfigString(key), `"' `)
+ if raw == "" {
+ return nil
+ }
+
+ items := []string{}
+ for _, item := range strings.Split(raw, ",") {
+ if trimmed := strings.Trim(item, `"' `); trimmed != "" {
+ items = append(items, trimmed)
+ }
+ }
+ return items
+}
+
// GetRelayToken is the token an agent sends to the local relay. It is generated
// on first start and stored with the settings, so it survives restarts and can
// be rotated from the Settings page.
diff --git a/main.go b/main.go
index 9edc80a..d75f6cd 100644
--- a/main.go
+++ b/main.go
@@ -17,6 +17,7 @@ package main
import (
"errors"
"fmt"
+ "net/http"
"os"
"time"
@@ -33,7 +34,6 @@ import (
"github.com/apache/casbin-gateway/util"
"github.com/apache/casbin-gateway/version"
"github.com/beego/beego"
- "github.com/beego/beego/plugins/cors"
_ "github.com/beego/beego/session/redis"
)
@@ -105,13 +105,10 @@ func main() {
object.StopLlmRecordWriter()
}
- beego.InsertFilter("*", beego.BeforeRouter, cors.Allow(&cors.Options{
- AllowOrigins: []string{"*"},
- AllowMethods: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"},
- AllowHeaders: []string{"Origin", "X-Requested-With", "Content-Type", "Accept"},
- ExposeHeaders: []string{"Content-Length"},
- AllowCredentials: true,
- }))
+ // Before anything else: a request from a browser has to be one this Gateway
+ // serves the pages of, not one a page on another site made with the
+ // operator's session.
+ beego.InsertFilter("*", beego.BeforeRouter, routers.OriginFilter)
// https://studygolang.com/articles/2303
beego.InsertFilter("/", beego.BeforeRouter, routers.TransparentStatic) // must has this for default page
@@ -126,6 +123,9 @@ func main() {
beego.BConfig.WebConfig.Session.SessionProviderConfig = beego.AppConfig.String("redisEndpoint")
}
beego.BConfig.WebConfig.Session.SessionGCMaxLifetime = 3600 * 24 * 365
+ // The session is only ever presented by Gateway's own pages, so the browser
+ // is told to keep it off requests another site makes.
+ beego.BConfig.WebConfig.Session.SessionCookieSameSite = http.SameSiteStrictMode
port := conf.GetHttpPort()
addr := conf.GetHttpAddr()
diff --git a/routers/origin.go b/routers/origin.go
new file mode 100644
index 0000000..20716aa
--- /dev/null
+++ b/routers/origin.go
@@ -0,0 +1,156 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package routers
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/beego/beego/context"
+)
+
+// OriginFilter keeps a page on another site from driving Gateway through the
+// browser of the person sitting at this machine. Loopback is not a trust
+// boundary in a browser: the local admin is signed in without a password, so
+// any page the operator visits can reach 127.0.0.1 with those rights.
+//
+// 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.
+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",
+ hostnameOf(host)))
+ return
+ }
+
+ origin := ctx.Input.Header("Origin")
+ 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))
+ return
+ }
+
+ // An origin the operator listed gets the CORS headers the browser needs to
+ // hand the answer to it.
+ writeCorsHeaders(ctx, origin)
+ if isPreflight(ctx) {
+ ctx.ResponseWriter.WriteHeader(http.StatusOK)
+ }
+}
+
+func denyOrigin(ctx *context.Context, message string) {
+ ctx.Output.SetStatus(http.StatusForbidden)
+ responseError(ctx, message)
+}
+
+// isAllowedHost reports whether the Host header names this machine. An address
+// is always one: rebinding needs a name to move, so a literal is what the
+// caller really dialed.
+func isAllowedHost(host string) bool {
+ name := hostnameOf(host)
+ if name == "" {
+ return true
+ }
+ if net.ParseIP(name) != nil {
+ return true
+ }
+
+ lower := strings.ToLower(name)
+ if lower == "localhost" || strings.HasSuffix(lower, ".localhost") {
+ return true
+ }
+ if isOwnHostname(lower) {
+ return true
+ }
+
+ for _, allowed := range conf.GetAllowedHosts() {
+ if strings.EqualFold(hostnameOf(allowed), name) {
+ return true
+ }
+ }
+ return false
+}
+
+// isOwnHostname matches this host's own name, both bare and as the first label
+// of the fully qualified form the same machine answers to.
+func isOwnHostname(lower string) bool {
+ own := strings.ToLower(util.GetHostname())
+ if own == "" {
+ return false
+ }
+ return lower == own || strings.HasPrefix(lower, own+".")
+}
+
+func isAllowedOrigin(origin string) bool {
+ for _, allowed := range conf.GetAllowedOrigins() {
+ if strings.EqualFold(strings.TrimSuffix(allowed, "/"), origin) {
+ return true
+ }
+ }
+ return false
+}
+
+// sameOrigin reports whether the Origin is Gateway itself, which is what every
+// page it serves sends: the web UI is served from here, and the dev server
+// proxies to here under its own name.
+func sameOrigin(origin string, host string) bool {
+ parsed, err := url.Parse(origin)
+ if err != nil || parsed.Host == "" {
+ return false
+ }
+ return strings.EqualFold(parsed.Host, host)
+}
+
+func isPreflight(ctx *context.Context) bool {
+ return ctx.Request.Method == http.MethodOptions && ctx.Input.Header("Access-Control-Request-Method") != ""
+}
+
+func writeCorsHeaders(ctx *context.Context, origin string) {
+ header := ctx.ResponseWriter.Header()
+ // The answer differs per origin, so a cache must not serve one origin's
+ // answer to another.
+ header.Add("Vary", "Origin")
+ header.Set("Access-Control-Allow-Origin", origin)
+ header.Set("Access-Control-Allow-Credentials", "true")
+ if !isPreflight(ctx) {
+ return
+ }
+
+ header.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
+ if requested := ctx.Input.Header("Access-Control-Request-Headers"); requested != "" {
+ header.Set("Access-Control-Allow-Headers", requested)
+ }
+ header.Set("Access-Control-Max-Age", "600")
+}
+
+func hostnameOf(host string) string {
+ if host == "" {
+ return ""
+ }
+ if name, _, err := net.SplitHostPort(host); err == nil {
+ return strings.Trim(name, "[]")
+ }
+ return strings.Trim(host, "[]")
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org