Files
waf-platform/EdgeNode/internal/nodes/http_request_encryption.go
2026-03-22 17:37:40 +08:00

516 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn .
package nodes
import (
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/TeaOSLab/EdgeNode/internal/encryption"
"github.com/TeaOSLab/EdgeNode/internal/remotelogs"
"github.com/andybalholm/brotli"
)
const encryptionCacheVersion = "xor-v1"
// processPageEncryption 处理页面加密
func (this *HTTPRequest) processPageEncryption(resp *http.Response) error {
// 首先检查是否是 /waf/loader.js如果是则直接跳过不应该被加密
// 这个检查必须在所有其他检查之前,确保 loader.js 永远不会被加密
if strings.Contains(this.URL(), "/waf/loader.js") {
return nil
}
if this.web.Encryption == nil {
return nil
}
if !this.web.Encryption.IsOn {
return nil
}
if !this.web.Encryption.IsEnabled() {
return nil
}
// 检查 URL 白名单
if this.web.Encryption.MatchExcludeURL(this.URL()) {
return nil
}
// 检查 Content-Type 和 URL
contentType := resp.Header.Get("Content-Type")
contentTypeLower := strings.ToLower(contentType)
urlLower := strings.ToLower(this.URL())
var isHTML = strings.Contains(contentTypeLower, "text/html")
// 判断是否为 JavaScript 文件:通过 Content-Type 或 URL 后缀
var isJavaScript = strings.Contains(contentTypeLower, "text/javascript") ||
strings.Contains(contentTypeLower, "application/javascript") ||
strings.Contains(contentTypeLower, "application/x-javascript") ||
strings.Contains(contentTypeLower, "text/ecmascript") ||
strings.HasSuffix(urlLower, ".js") ||
strings.Contains(urlLower, ".js?") ||
strings.Contains(urlLower, ".js&")
if !isHTML && !isJavaScript {
return nil
}
// 检查内容大小(仅处理小于 10MB 的内容)
if resp.ContentLength > 0 && resp.ContentLength > 10*1024*1024 {
return nil
}
// 读取响应体
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
_ = resp.Body.Close()
// 如果源站返回了压缩内容,先解压再处理
decodedBody, decoded, err := decodeResponseBody(bodyBytes, resp.Header.Get("Content-Encoding"))
if err == nil && decoded {
bodyBytes = decodedBody
// 已经解压,移除 Content-Encoding
resp.Header.Del("Content-Encoding")
}
// 检查实际大小
if len(bodyBytes) > 10*1024*1024 {
// 内容太大,恢复原始响应体
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
var encryptedBytes []byte
// 处理 JavaScript 文件
if isJavaScript {
// 检查是否需要加密独立的 JavaScript 文件
if this.web.Encryption.Javascript == nil {
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
if !this.web.Encryption.Javascript.IsOn {
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
// 检查 URL 匹配
if !this.web.Encryption.Javascript.MatchURL(this.URL()) {
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
// 跳过 Loader 文件
if strings.Contains(this.URL(), "/waf/loader.js") ||
strings.Contains(this.URL(), "waf-loader.js") ||
strings.Contains(this.URL(), "__WAF_") {
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
encryptedBytes, err = this.encryptJavaScriptFile(bodyBytes, resp)
if err != nil {
remotelogs.Warn("HTTP_REQUEST_ENCRYPTION", "encrypt JavaScript file failed: "+err.Error())
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
} else if isHTML {
// 加密 HTML 内容
encryptedBytes, err = this.encryptHTMLScripts(bodyBytes, resp)
if err != nil {
remotelogs.Warn("HTTP_REQUEST_ENCRYPTION", "encrypt HTML failed: "+err.Error())
// 加密失败,恢复原始响应体
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
} else {
// 未知类型,恢复原始响应体
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return nil
}
// 替换响应体
resp.Body = io.NopCloser(bytes.NewReader(encryptedBytes))
resp.ContentLength = int64(len(encryptedBytes))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(encryptedBytes)))
// 避免旧缓存导致解密算法不匹配
resp.Header.Set("Cache-Control", "no-store, no-cache, must-revalidate")
// 删除 Content-Encoding如果存在因为我们修改了内容
resp.Header.Del("Content-Encoding")
return nil
}
// encryptHTMLScripts 加密 HTML 中的脚本
func (this *HTTPRequest) encryptHTMLScripts(htmlBytes []byte, resp *http.Response) ([]byte, error) {
html := string(htmlBytes)
// 检查是否需要加密 HTML 脚本
if this.web.Encryption.HTML == nil || !this.web.Encryption.HTML.IsOn {
return htmlBytes, nil
}
// 检查 URL 匹配
if !this.web.Encryption.HTML.MatchURL(this.URL()) {
return htmlBytes, nil
}
// 生成密钥
remoteIP := this.requestRemoteAddr(true)
userAgent := this.RawReq.UserAgent()
keyID, actualKey := encryption.GenerateEncryptionKey(remoteIP, userAgent, this.web.Encryption.KeyPolicy)
// 检查缓存
var cacheKey string
if this.web.Encryption.Cache != nil && this.web.Encryption.Cache.IsOn {
// 生成缓存键keyID + URL + contentHash
contentHash := fmt.Sprintf("%x", bytesHash(htmlBytes))
cacheKey = fmt.Sprintf("encrypt_%s_%s_%s_%s", encryptionCacheVersion, keyID, this.URL(), contentHash)
cache := encryption.SharedEncryptionCache(
int(this.web.Encryption.Cache.MaxSize),
time.Duration(this.web.Encryption.Cache.TTL)*time.Second,
)
if cached, ok := cache.Get(cacheKey); ok {
return cached, nil
}
}
// 提取并加密内联脚本
if this.web.Encryption.HTML.EncryptInlineScripts {
html = this.encryptInlineScripts(html, actualKey, keyID)
}
// 提取并加密外部脚本(通过 src 属性)
if this.web.Encryption.HTML.EncryptExternalScripts {
html = this.encryptExternalScripts(html, actualKey, keyID)
}
// 注入 Loader
html = this.injectLoader(html)
result := []byte(html)
// 保存到缓存
if this.web.Encryption.Cache != nil && this.web.Encryption.Cache.IsOn && len(cacheKey) > 0 {
cache := encryption.SharedEncryptionCache(
int(this.web.Encryption.Cache.MaxSize),
time.Duration(this.web.Encryption.Cache.TTL)*time.Second,
)
cache.Set(cacheKey, result, this.web.Encryption.Cache.TTL)
}
return result, nil
}
// encryptInlineScripts 加密内联脚本
func (this *HTTPRequest) encryptInlineScripts(html string, key string, keyID string) string {
// 匹配 <script>...</script>(不包含 src 属性)
scriptRegex := regexp.MustCompile(`(?i)<script(?:\s+[^>]*)?>([\s\S]*?)</script>`)
return scriptRegex.ReplaceAllStringFunc(html, func(match string) string {
// 检查是否有 src 属性(外部脚本)
if strings.Contains(strings.ToLower(match), "src=") {
return match // 跳过外部脚本
}
// 提取脚本内容
contentMatch := regexp.MustCompile(`(?i)<script(?:\s+[^>]*)?>([\s\S]*?)</script>`)
matches := contentMatch.FindStringSubmatch(match)
if len(matches) < 2 {
return match
}
scriptContent := matches[1]
// 跳过空脚本或仅包含空白字符的脚本
if strings.TrimSpace(scriptContent) == "" {
return match
}
// 跳过已加密的脚本(包含 __WAF_P__
if strings.Contains(scriptContent, "__WAF_P__") {
return match
}
// 加密脚本内容
encrypted, err := this.encryptScript(scriptContent, key)
if err != nil {
return match // 加密失败,返回原始内容
}
// 生成元数据k 是 keyID用于缓存key 是实际密钥,用于解密)
meta := fmt.Sprintf(`{"k":"%s","key":"%s","t":%d,"alg":"xor"}`, keyID, key, time.Now().Unix())
// 替换为加密后的脚本(同步解密执行,保证脚本顺序)
return fmt.Sprintf(
`<script>(function(){
function xorDecodeToString(b64,key){
var bin=atob(b64);
var out=new Uint8Array(bin.length);
for(var i=0;i<bin.length;i++){out[i]=bin.charCodeAt(i)^key.charCodeAt(i%%key.length);}
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(out);
}
var s='';for(var j=0;j<out.length;j++){s+=String.fromCharCode(out[j]);}
return s;
}
try{var meta=%s;var code=xorDecodeToString("%s",meta.key);window.eval(code);}catch(e){console.error('WAF inline decrypt/execute failed',e);}
})();</script>`,
meta,
encrypted,
)
})
}
// encryptExternalScripts 加密外部脚本(通过替换 src 为加密后的内容)
// 注意:这里我们实际上是将外部脚本的内容内联并加密
func (this *HTTPRequest) encryptExternalScripts(html string, key string, keyID string) string {
// 匹配 <script src="..."></script>
scriptRegex := regexp.MustCompile(`(?i)<script\s+([^>]*src\s*=\s*["']([^"']+)["'][^>]*)>\s*</script>`)
return scriptRegex.ReplaceAllStringFunc(html, func(match string) string {
// 提取 src URL
srcMatch := regexp.MustCompile(`(?i)src\s*=\s*["']([^"']+)["']`)
srcMatches := srcMatch.FindStringSubmatch(match)
if len(srcMatches) < 2 {
return match
}
srcURL := srcMatches[1]
// 跳过已加密的脚本或 Loader
if strings.Contains(srcURL, "waf-loader.js") || strings.Contains(srcURL, "__WAF_") {
return match
}
// 注意:实际实现中,我们需要获取外部脚本的内容
// 这里为了简化,我们只是标记需要加密,实际内容获取需要在响应处理时进行
// 当前实现:将外部脚本转换为内联加密脚本的占位符
// 实际生产环境需要1. 获取外部脚本内容 2. 加密 3. 替换
return match // 暂时返回原始内容,后续可以扩展
})
}
// encryptScript 加密脚本内容
func (this *HTTPRequest) encryptScript(scriptContent string, key string) (string, error) {
// 1. XOR 加密(不压缩,避免浏览器解压依赖导致顺序问题)
encrypted := xorEncrypt([]byte(scriptContent), []byte(key))
// 2. Base64 编码
encoded := base64.StdEncoding.EncodeToString(encrypted)
return encoded, nil
}
// injectLoader 注入 Loader 脚本
func (this *HTTPRequest) injectLoader(html string) string {
// 检查是否已经注入
if strings.Contains(html, "waf-loader.js") {
return html
}
// 在 </head> 之前注入,如果没有 </head>,则在 </body> 之前注入
// 不使用 async确保 loader 在解析阶段优先加载并执行
loaderScript := `<script src="/waf/loader.js"></script>`
if strings.Contains(html, "</head>") {
return strings.Replace(html, "</head>", loaderScript+"</head>", 1)
} else if strings.Contains(html, "</body>") {
return strings.Replace(html, "</body>", loaderScript+"</body>", 1)
} else {
// 如果都没有,在开头注入
return loaderScript + html
}
}
// compressGzip 使用 Gzip 压缩(浏览器原生支持)
func compressGzip(data []byte) ([]byte, error) {
var buf bytes.Buffer
writer := gzip.NewWriter(&buf)
_, err := writer.Write(data)
if err != nil {
writer.Close()
return nil, err
}
err = writer.Close()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// decodeResponseBody 根据 Content-Encoding 解压响应体
func decodeResponseBody(body []byte, encoding string) ([]byte, bool, error) {
enc := strings.ToLower(strings.TrimSpace(encoding))
if enc == "" || enc == "identity" {
return body, false, nil
}
switch enc {
case "gzip":
reader, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return body, false, err
}
defer reader.Close()
decoded, err := io.ReadAll(reader)
if err != nil {
return body, false, err
}
return decoded, true, nil
case "br":
reader := brotli.NewReader(bytes.NewReader(body))
decoded, err := io.ReadAll(reader)
if err != nil {
return body, false, err
}
return decoded, true, nil
default:
// 未知编码,保持原样
return body, false, nil
}
}
// compressBrotli 使用 Brotli 压缩(保留用于其他用途)
func compressBrotli(data []byte, level int) ([]byte, error) {
var buf bytes.Buffer
writer := brotli.NewWriterOptions(&buf, brotli.WriterOptions{
Quality: level,
LGWin: 14,
})
_, err := writer.Write(data)
if err != nil {
writer.Close()
return nil, err
}
err = writer.Close()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// xorEncrypt XOR 加密
func xorEncrypt(data []byte, key []byte) []byte {
result := make([]byte, len(data))
keyLen := len(key)
if keyLen == 0 {
return data
}
for i := 0; i < len(data); i++ {
result[i] = data[i] ^ key[i%keyLen]
}
return result
}
// encryptJavaScriptFile 加密独立的 JavaScript 文件
func (this *HTTPRequest) encryptJavaScriptFile(jsBytes []byte, resp *http.Response) ([]byte, error) {
jsContent := string(jsBytes)
// 跳过空文件
if strings.TrimSpace(jsContent) == "" {
return jsBytes, nil
}
// 跳过已加密的脚本(包含 __WAF_P__
if strings.Contains(jsContent, "__WAF_P__") {
return jsBytes, nil
}
// 生成密钥
remoteIP := this.requestRemoteAddr(true)
userAgent := this.RawReq.UserAgent()
keyID, actualKey := encryption.GenerateEncryptionKey(remoteIP, userAgent, this.web.Encryption.KeyPolicy)
// 检查缓存
var cacheKey string
if this.web.Encryption.Cache != nil && this.web.Encryption.Cache.IsOn {
// 生成缓存键keyID + URL + contentHash
contentHash := fmt.Sprintf("%x", bytesHash(jsBytes))
cacheKey = fmt.Sprintf("encrypt_js_%s_%s_%s_%s", encryptionCacheVersion, keyID, this.URL(), contentHash)
cache := encryption.SharedEncryptionCache(
int(this.web.Encryption.Cache.MaxSize),
time.Duration(this.web.Encryption.Cache.TTL)*time.Second,
)
if cached, ok := cache.Get(cacheKey); ok {
return cached, nil
}
}
// 加密脚本内容
encrypted, err := this.encryptScript(jsContent, actualKey)
if err != nil {
return nil, err
}
// 生成元数据k 是 keyID用于缓存key 是实际密钥,用于解密)
meta := fmt.Sprintf(`{"k":"%s","key":"%s","t":%d,"alg":"xor"}`, keyID, actualKey, time.Now().Unix())
// 生成加密后的 JavaScript 代码(同步解密执行,保证脚本顺序)
encryptedJS := fmt.Sprintf(`(function() {
try {
function xorDecodeToString(b64, key) {
var bin = atob(b64);
var out = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) {
out[i] = bin.charCodeAt(i) ^ key.charCodeAt(i %% key.length);
}
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(out);
}
var s = '';
for (var j = 0; j < out.length; j++) {
s += String.fromCharCode(out[j]);
}
return s;
}
var meta = %s;
var code = xorDecodeToString("%s", meta.key);
// 使用全局 eval尽量保持和 <script> 一致的作用域
window.eval(code);
} catch (e) {
console.error('WAF JS decrypt/execute failed', e);
}
})();`, meta, encrypted)
result := []byte(encryptedJS)
// 保存到缓存
if this.web.Encryption.Cache != nil && this.web.Encryption.Cache.IsOn && len(cacheKey) > 0 {
cache := encryption.SharedEncryptionCache(
int(this.web.Encryption.Cache.MaxSize),
time.Duration(this.web.Encryption.Cache.TTL)*time.Second,
)
cache.Set(cacheKey, result, this.web.Encryption.Cache.TTL)
}
return result, nil
}
// bytesHash 计算字节数组的简单哈希(用于缓存键)
func bytesHash(data []byte) uint64 {
var hash uint64 = 5381
for _, b := range data {
hash = ((hash << 5) + hash) + uint64(b)
}
return hash
}