Files
casdoor/service/proxy.go

373 lines
10 KiB
Go

// Copyright 2023 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 service
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"path/filepath"
"strings"
"github.com/beego/beego/v2/core/logs"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/object"
"github.com/casdoor/casdoor/rule"
"github.com/casdoor/casdoor/util"
)
func forwardHandler(targetUrl string, writer http.ResponseWriter, request *http.Request) {
target, err := url.Parse(targetUrl)
if nil != err {
panic(err)
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Director = func(r *http.Request) {
r.URL = target
if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" && xff != clientIP {
newXff := fmt.Sprintf("%s, %s", xff, clientIP)
// r.Header.Set("X-Forwarded-For", newXff)
r.Header.Set("X-Real-Ip", newXff)
} else {
// r.Header.Set("X-Forwarded-For", clientIP)
r.Header.Set("X-Real-Ip", clientIP)
}
}
}
proxy.ModifyResponse = func(resp *http.Response) error {
// Add Secure flag to all Set-Cookie headers in HTTPS responses
if request.TLS != nil {
// Add HSTS header for HTTPS responses if not already set by backend
if resp.Header.Get("Strict-Transport-Security") == "" {
resp.Header.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
cookies := resp.Header["Set-Cookie"]
if len(cookies) > 0 {
// Clear existing Set-Cookie headers
resp.Header.Del("Set-Cookie")
// Add them back with Secure flag if not already present
for _, cookie := range cookies {
// Check if Secure attribute is already present (case-insensitive)
cookieLower := strings.ToLower(cookie)
hasSecure := strings.Contains(cookieLower, ";secure;") ||
strings.Contains(cookieLower, "; secure;") ||
strings.HasSuffix(cookieLower, ";secure") ||
strings.HasSuffix(cookieLower, "; secure")
if !hasSecure {
cookie = cookie + "; Secure"
}
resp.Header.Add("Set-Cookie", cookie)
}
}
}
// Fix CORS issue: Remove CORS header combinations that allow credential theft from any origin
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
allowCredentials := resp.Header.Get("Access-Control-Allow-Credentials")
// Remove CORS headers when the combination is present:
// 1. Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *
// This is actually blocked by browsers but we sanitize it anyway
// 2. Access-Control-Allow-Credentials: true with any origin
// Without a configured allowlist, we cannot safely validate if the origin
// is trusted or if it's being reflected from the request, so we remove all
// CORS headers for credential-bearing responses to prevent theft
if strings.EqualFold(allowCredentials, "true") && allowOrigin != "" {
// Remove CORS headers to prevent credential theft
resp.Header.Del("Access-Control-Allow-Origin")
resp.Header.Del("Access-Control-Allow-Credentials")
resp.Header.Del("Access-Control-Allow-Methods")
resp.Header.Del("Access-Control-Allow-Headers")
resp.Header.Del("Access-Control-Expose-Headers")
resp.Header.Del("Access-Control-Max-Age")
}
return nil
}
proxy.ServeHTTP(writer, request)
}
func getHostNonWww(host string) string {
res := ""
tokens := strings.Split(host, ".")
if len(tokens) > 2 && tokens[0] == "www" {
res = strings.Join(tokens[1:], ".")
}
return res
}
func logRequest(clientIp string, r *http.Request) {
if !strings.Contains(r.UserAgent(), "Uptime-Kuma") {
fmt.Printf("handleRequest: %s\t%s\t%s\t%s\t%s\t%s\n", clientIp, r.Method, r.Host, r.RequestURI, r.UserAgent(), r.RemoteAddr)
record := object.Record{
Owner: "admin",
CreatedTime: util.GetCurrentTime(),
Method: r.Method,
RequestUri: r.RequestURI,
ClientIp: clientIp,
}
object.AddRecord(&record)
}
}
func redirectToHttps(w http.ResponseWriter, r *http.Request) {
targetUrl := fmt.Sprintf("https://%s", joinPath(r.Host, r.RequestURI))
http.Redirect(w, r, targetUrl, http.StatusMovedPermanently)
}
func redirectToHost(w http.ResponseWriter, r *http.Request, host string) {
protocol := "https"
if r.TLS == nil {
protocol = "http"
}
targetUrl := fmt.Sprintf("%s://%s", protocol, joinPath(host, r.RequestURI))
http.Redirect(w, r, targetUrl, http.StatusMovedPermanently)
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
clientIp := util.GetClientIp(r)
logRequest(clientIp, r)
site := getSiteByDomainWithWww(r.Host)
if site == nil {
if isHostIp(r.Host) {
w.WriteHeader(http.StatusBadRequest)
return
}
if strings.HasSuffix(r.Host, ".casdoor.com") && r.RequestURI == "/health-ping" {
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintf(w, "OK")
if err != nil {
panic(err)
}
return
}
responseError(w, "CasWAF error: site not found for host: %s", r.Host)
return
}
hostNonWww := getHostNonWww(r.Host)
if hostNonWww != "" {
redirectToHost(w, r, hostNonWww)
return
}
if site.Domain != r.Host && site.NeedRedirect {
redirectToHost(w, r, site.Domain)
return
}
if strings.HasPrefix(r.RequestURI, "/.well-known/acme-challenge/") {
challengeMap := site.GetChallengeMap()
for token, keyAuth := range challengeMap {
if r.RequestURI == fmt.Sprintf("/.well-known/acme-challenge/%s", token) {
responseOk(w, "%s", keyAuth)
return
}
}
responseError(w, "CasWAF error: ACME HTTP-01 challenge failed, requestUri cannot match with challengeMap, requestUri = %s, challengeMap = %v", r.RequestURI, challengeMap)
return
}
if strings.HasPrefix(r.RequestURI, "/MP_verify_") {
challengeMap := site.GetChallengeMap()
for path, value := range challengeMap {
if r.RequestURI == fmt.Sprintf("/%s", path) {
responseOk(w, "%s", value)
return
}
}
}
if site.SslMode == "HTTPS Only" {
// This domain only supports https but receive http request, redirect to https
if r.TLS == nil {
redirectToHttps(w, r)
return
}
}
// oAuth proxy
if site.CasdoorApplication != "" {
// handle oAuth proxy
cookie, err := r.Cookie("casdoor_access_token")
if err != nil && err.Error() != "http: named cookie not present" {
panic(err)
}
casdoorClient, err := getCasdoorClientFromSite(site)
if err != nil {
responseError(w, "CasWAF error: getCasdoorClientFromSite() error: %s", err.Error())
return
}
if cookie == nil {
// not logged in
redirectToCasdoor(casdoorClient, w, r)
return
} else {
_, err = casdoorClient.ParseJwtToken(cookie.Value)
if err != nil {
responseError(w, "CasWAF error: casdoorClient.ParseJwtToken() error: %s", err.Error())
return
}
}
}
host := site.GetHost()
if host == "" {
responseError(w, "CasWAF error: targetUrl should not be empty for host: %s, site = %v", r.Host, site)
return
}
if len(site.Rules) == 0 {
nextHandle(w, r)
return
}
result, err := rule.CheckRules(site.Rules, r)
if err != nil {
responseError(w, "Internal Server Error: %v", err)
return
}
reason := result.Reason
if reason != "" && site.DisableVerbose {
reason = "the rule has been hit"
}
switch result.Action {
case "", "Allow":
// Do not write header for Allow action, let the proxy handle it
case "Block":
w.WriteHeader(result.StatusCode)
responseErrorWithoutCode(w, "Blocked by CasWAF: %s", reason)
return
case "Drop":
w.WriteHeader(result.StatusCode)
responseErrorWithoutCode(w, "Dropped by CasWAF: %s", reason)
return
default:
responseError(w, "Error in CasWAF: %s", reason)
}
nextHandle(w, r)
}
func nextHandle(w http.ResponseWriter, r *http.Request) {
site := getSiteByDomainWithWww(r.Host)
host := site.GetHost()
if site.SslMode == "Static Folder" {
var path string
if r.RequestURI != "/" {
path = filepath.Join(host, r.RequestURI)
} else {
path = filepath.Join(host, "/index.htm")
if !util.FileExist(path) {
path = filepath.Join(host, "/index.html")
if !util.FileExist(path) {
path = filepath.Join(host, r.RequestURI)
}
}
}
http.ServeFile(w, r, path)
} else {
targetUrl := joinPath(site.GetHost(), r.RequestURI)
forwardHandler(targetUrl, w, r)
}
}
func Start() {
serverMux := http.NewServeMux()
serverMux.HandleFunc("/", handleRequest)
serverMux.HandleFunc("/caswaf-handler", handleAuthCallback)
gatewayHttpPort, err := conf.GetConfigInt64("gatewayHttpPort")
if err != nil {
gatewayHttpPort = 80
}
gatewayHttpsPort, err := conf.GetConfigInt64("gatewayHttpsPort")
if err != nil {
gatewayHttpsPort = 443
}
go func() {
fmt.Printf("CasWAF gateway running on: http://127.0.0.1:%d\n", gatewayHttpPort)
err := http.ListenAndServe(fmt.Sprintf(":%d", gatewayHttpPort), serverMux)
if err != nil {
logs.Error(err)
}
}()
go func() {
fmt.Printf("CasWAF gateway running on: https://127.0.0.1:%d\n", gatewayHttpsPort)
server := &http.Server{
Handler: serverMux,
Addr: fmt.Sprintf(":%d", gatewayHttpsPort),
TLSConfig: &tls.Config{
// Minimum TLS version 1.2, TLS 1.3 is automatically supported
MinVersion: tls.VersionTLS12,
// Secure cipher suites for TLS 1.2 (excluding 3DES to prevent Sweet32 attack)
// TLS 1.3 cipher suites are automatically configured by Go
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
},
// Prefer strong elliptic curves
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
tls.CurveP384,
},
},
}
// start https server and set how to get certificate
server.TLSConfig.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
domain := info.ServerName
cert, err := getX509CertByDomain(domain)
if err != nil {
return nil, err
}
return cert, nil
}
err := server.ListenAndServeTLS("", "")
if err != nil {
logs.Error(err)
}
}()
}