mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
feat(option): add sw offline
fix(openresty): scope sw injection per cert partition fix(lint): satisfy revive and biome format for sw offline feature docs: sw offline scope changelog fix(frontend): use scoped query key for sw scope zones fix(frontend): hide preview link in sw contact page editor feat(frontend): add sw scope domain picker and contact page fields refactor(frontend): generalize html editor workspace for reuse feat(openresty): scope sw offline injection by route domains feat(openresty): add sw offline domains snapshot field feat(option): add sw offline domains scope option docs: fill html editor workspace generalization detail docs: sw offline scope implementation plan docs: sw offline scope design test(openresty): assert single merged access block in sw enabled servers fix(openresty): restrict sw intercept to https server blocks fix(openresty): version sw offline cache by html content fix(agent): escape redir in sw challenge page to prevent xss fix(agent): return sw.runtime module table and add lua spec docs: sw offline fallback changelog fix(frontend): memoize option map to preserve unsaved contact page edits feat(frontend): add response pages module with contact page tab feat(agent): ship sw offline lua assets and placeholder substitution feat(config): wire sw offline options into config snapshot feat(openresty): render sw offline assets and challenge intercept feat(openresty): add sw offline ConfigSnapshot fields and placeholder feat(db): seed sw offline options feat(option): add sw offline config keys and validation docs: add service worker offline fallback implementation plan docs: adopt global-option pattern for SW offline fallback (matches origin error page) docs: unify offline contact page with error pages as response pages docs: service worker offline fallback design (issue #23)
This commit is contained in:
@@ -401,6 +401,7 @@ func (m *Manager) EnsureLuaAssets() error {
|
||||
return fmt.Errorf("load pow static files: %w", err)
|
||||
}
|
||||
allSupportFiles = append(allSupportFiles, powStaticFiles...)
|
||||
allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...)
|
||||
files := make([]managedFile, 0, len(allSupportFiles))
|
||||
for _, file := range allSupportFiles {
|
||||
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
|
||||
@@ -525,6 +526,8 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
// Longer error-page path must be restored before the cert-dir prefix rewrite.
|
||||
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, errorPagePath, openrestyrender.ErrorPageTmplPlaceholder)
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
@@ -1022,6 +1025,7 @@ func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error {
|
||||
}
|
||||
|
||||
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
|
||||
certFiles = append(certFiles, ManagedSWLuaFiles()...)
|
||||
files := make([]managedFile, 0, len(certFiles))
|
||||
for _, file := range certFiles {
|
||||
if file.Path == powConfigFileName || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName {
|
||||
@@ -1115,6 +1119,20 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Agent-shipped SW Lua assets are not part of the rendered bundle, so they
|
||||
// must stay out of the bundle checksum to keep it aligned with the server.
|
||||
swManaged := make(map[string]struct{}, len(ManagedSWLuaFiles()))
|
||||
for _, file := range ManagedSWLuaFiles() {
|
||||
swManaged[file.Path] = struct{}{}
|
||||
}
|
||||
kept := files[:0]
|
||||
for _, file := range files {
|
||||
if _, skip := swManaged[file.Path]; skip {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, file)
|
||||
}
|
||||
files = kept
|
||||
powConfig, err := m.readPowConfigFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1380,6 +1398,8 @@ func (m *Manager) renderRouteConfig(content string) string {
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
|
||||
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorPageTmplPlaceholder, errorPagePath)
|
||||
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||
|
||||
@@ -337,6 +337,85 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyShipsSWAssetsAndReplacesSWDirPlaceholder(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manager := &Manager{
|
||||
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
|
||||
RouteConfigPath: filepath.Join(tempDir, "routes.conf"),
|
||||
CertDir: filepath.Join(tempDir, "certs"),
|
||||
NginxCertDir: "/etc/nginx/openflare-certs",
|
||||
LuaDir: filepath.Join(tempDir, "lua"),
|
||||
NginxLuaDir: "/etc/nginx/openflare-lua",
|
||||
Executor: &fakeExecutor{},
|
||||
}
|
||||
|
||||
outcome := manager.Apply(
|
||||
context.Background(),
|
||||
"include __OPENFLARE_ROUTE_CONFIG__;",
|
||||
"alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")",
|
||||
[]protocol.SupportFile{
|
||||
{Path: "sw/sw.js", Content: "js"},
|
||||
{Path: "sw/offline.html", Content: "html"},
|
||||
},
|
||||
)
|
||||
if outcome.Status != ApplyStatusSuccess {
|
||||
t.Fatalf("Apply failed: %#v", outcome)
|
||||
}
|
||||
|
||||
routeData, err := os.ReadFile(manager.RouteConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
rendered := string(routeData)
|
||||
for _, want := range []string{
|
||||
"/etc/nginx/openflare-certs/sw/sw.js",
|
||||
"/etc/nginx/openflare-certs/sw/offline.html",
|
||||
"/etc/nginx/openflare-certs/sw/challenge.lua",
|
||||
} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("route config missing %q, got %s", want, rendered)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, openrestyrender.SWDirPlaceholder) {
|
||||
t.Fatalf("route config still contains SW dir placeholder: %s", rendered)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
filepath.Join(manager.CertDir, "sw", "sw.js"),
|
||||
filepath.Join(manager.CertDir, "sw", "offline.html"),
|
||||
filepath.Join(manager.CertDir, "sw", "challenge.lua"),
|
||||
filepath.Join(manager.CertDir, "sw", "runtime.lua"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected SW asset %s to exist: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
filepath.Join(manager.LuaDir, "sw", "challenge.lua"),
|
||||
filepath.Join(manager.LuaDir, "sw", "runtime.lua"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected SW lua asset %s to exist: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
checksum, err := manager.CurrentChecksum()
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentChecksum failed: %v", err)
|
||||
}
|
||||
expected := bundleChecksum(
|
||||
"include __OPENFLARE_ROUTE_CONFIG__;",
|
||||
"alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")",
|
||||
[]protocol.SupportFile{
|
||||
{Path: "sw/sw.js", Content: "js"},
|
||||
{Path: "sw/offline.html", Content: "html"},
|
||||
},
|
||||
)
|
||||
if checksum != expected {
|
||||
t.Fatalf("unexpected checksum: got %s want %s", checksum, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRenderMainConfigInitializesWAFRuntimeInWorker(t *testing.T) {
|
||||
manager := &Manager{NginxLuaDir: "/etc/nginx/openflare-lua"}
|
||||
rendered := manager.renderMainConfig("events {}\nhttp {\n lua_shared_dict openflare_waf_config 1m;\n server {}\n}\n")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
)
|
||||
|
||||
const openRestySWRuntimeLua = `local _M = {}
|
||||
|
||||
local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
local function is_real_browser(ua)
|
||||
if not ua or ua == "" then return false end
|
||||
-- Chrome/Edge/CentOS-style: "Chrome/120" (pattern mode: %d = digit)
|
||||
if string.find(ua, "Chrome/%d", 1) then return true end
|
||||
-- Firefox: "Firefox/120"
|
||||
if string.find(ua, "Firefox/%d", 1) then return true end
|
||||
-- Safari (non-Chrome, e.g. "Version/17.0 Safari")
|
||||
if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pass_through()
|
||||
return true
|
||||
end
|
||||
|
||||
function _M.check()
|
||||
local ua = ngx.var.http_user_agent or ""
|
||||
if not is_real_browser(ua) then return pass_through() end
|
||||
|
||||
local uri = ngx.var.uri or ""
|
||||
if uri ~= "/" then return pass_through() end
|
||||
|
||||
if ngx.req.get_method and ngx.req.get_method() ~= "GET" then return pass_through() end
|
||||
|
||||
local cookie = ngx.var["cookie___openflare_sw"]
|
||||
if cookie and cookie ~= "" then return pass_through() end
|
||||
|
||||
-- intercept: internal redirect to challenge page, which registers SW + sets cookie
|
||||
local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "")
|
||||
ngx.req.set_uri_args({ redir = redir })
|
||||
return ngx.exec("/__openflare_sw_challenge")
|
||||
end
|
||||
|
||||
return _M
|
||||
`
|
||||
|
||||
const openRestySWChallengeLua = `local args = ngx.req.get_uri_args()
|
||||
local redir = args["redir"] or "/"
|
||||
|
||||
-- Escape redir for embedding inside a JS string literal within an HTML
|
||||
-- <script> element. Backslashes first so later escapes stay escaped, then
|
||||
-- double quotes (string-literal break-out), then "<" (prevents a raw
|
||||
-- "</script" sequence ending the element, which the HTML parser matches
|
||||
-- case-insensitively), then CR/LF (a raw newline would end the literal).
|
||||
local function escape_redir(value)
|
||||
local escaped = string.gsub(value, "\\", "\\\\")
|
||||
escaped = string.gsub(escaped, '"', '\\"')
|
||||
escaped = string.gsub(escaped, "<", "\\x3C")
|
||||
escaped = string.gsub(escaped, "\r", "\\r")
|
||||
escaped = string.gsub(escaped, "\n", "\\n")
|
||||
return escaped
|
||||
end
|
||||
redir = escape_redir(redir)
|
||||
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.say([[<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>加载中...</title>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").then(function () {
|
||||
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
|
||||
location.replace("]] .. redir .. [[");
|
||||
}).catch(function () {
|
||||
location.replace("]] .. redir .. [[");
|
||||
});
|
||||
} else {
|
||||
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
|
||||
location.replace("]] .. redir .. [[");
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>正在加载...</body>
|
||||
</html>]])
|
||||
`
|
||||
|
||||
// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge.
|
||||
func ManagedSWLuaFiles() []protocol.SupportFile {
|
||||
return []protocol.SupportFile{
|
||||
{Path: "sw/runtime.lua", Content: openRestySWRuntimeLua},
|
||||
{Path: "sw/challenge.lua", Content: openRestySWChallengeLua},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestSWRuntimeAndChallenge(t *testing.T) {
|
||||
state := lua.NewState()
|
||||
defer state.Close()
|
||||
|
||||
runtimePath := filepath.Join(t.TempDir(), "runtime.lua")
|
||||
if err := os.WriteFile(runtimePath, []byte(openRestySWRuntimeLua), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challengePath := filepath.Join(t.TempDir(), "challenge.lua")
|
||||
if err := os.WriteFile(challengePath, []byte(openRestySWChallengeLua), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specPath, err := filepath.Abs("sw_runtime_spec.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.SetGlobal("SW_RUNTIME_PATH", lua.LString(runtimePath))
|
||||
state.SetGlobal("SW_CHALLENGE_PATH", lua.LString(challengePath))
|
||||
if err := state.DoFile(specPath); err != nil {
|
||||
t.Fatalf("SW runtime/challenge specification failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
local runtime_path = assert(SW_RUNTIME_PATH, "SW_RUNTIME_PATH is required")
|
||||
local challenge_path = assert(SW_CHALLENGE_PATH, "SW_CHALLENGE_PATH is required")
|
||||
|
||||
local function assert_equal(actual, expected, message)
|
||||
if actual ~= expected then
|
||||
error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
|
||||
end
|
||||
end
|
||||
|
||||
-- Stable tables: never rebind `exec_calls` / `redir_args` (closures capture
|
||||
-- the upvalue slot; rebinding can leave stale values visible under
|
||||
-- gopher-lua across long test sequences). Clear them in place instead.
|
||||
local output = {}
|
||||
local exec_calls = {}
|
||||
local redir_args = {}
|
||||
|
||||
local function clear_state()
|
||||
for i = 1, #exec_calls do exec_calls[i] = nil end
|
||||
redir_args.redir = nil
|
||||
end
|
||||
|
||||
ngx = {
|
||||
var = {},
|
||||
header = {},
|
||||
exec = function(uri)
|
||||
exec_calls[#exec_calls + 1] = uri
|
||||
return true
|
||||
end,
|
||||
say = function(body) output.body = body end,
|
||||
req = {
|
||||
get_uri_args = function() return redir_args end,
|
||||
set_uri_args = function(args) redir_args.redir = args.redir end,
|
||||
},
|
||||
}
|
||||
|
||||
local function load_runtime()
|
||||
local chunk = assert(loadfile(runtime_path))
|
||||
return chunk()
|
||||
end
|
||||
|
||||
local function reset_request(user_agent, uri, cookie, args, method)
|
||||
clear_state()
|
||||
ngx.var = {
|
||||
http_user_agent = user_agent,
|
||||
uri = uri or "/",
|
||||
scheme = "https",
|
||||
host = "example.com",
|
||||
args = args,
|
||||
["cookie___openflare_sw"] = cookie,
|
||||
}
|
||||
ngx.req.get_method = function() return method or "GET" end
|
||||
end
|
||||
|
||||
local function test_module_contract()
|
||||
local runtime = load_runtime()
|
||||
assert_equal(type(runtime), "table", "sw.runtime must return a module table, not true/nil")
|
||||
assert_equal(type(runtime.check), "function", "sw.runtime must export check()")
|
||||
end
|
||||
|
||||
local function test_non_browser_ua_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request("curl/8.0.1")
|
||||
assert_equal(runtime.check(), true, "non-browser UA passes through")
|
||||
assert_equal(#exec_calls, 0, "non-browser UA must not intercept")
|
||||
|
||||
reset_request("")
|
||||
assert_equal(runtime.check(), true, "empty UA passes through")
|
||||
|
||||
reset_request(nil)
|
||||
assert_equal(runtime.check(), true, "missing UA passes through")
|
||||
end
|
||||
|
||||
local function test_browser_ua_non_get_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"/",
|
||||
nil,
|
||||
nil,
|
||||
"POST"
|
||||
)
|
||||
assert_equal(runtime.check(), true, "non-GET request passes through")
|
||||
assert_equal(#exec_calls, 0, "non-GET request must not be intercepted")
|
||||
end
|
||||
|
||||
local function test_browser_ua_with_cookie_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"/",
|
||||
"1"
|
||||
)
|
||||
assert_equal(runtime.check(), true, "browser UA with cookie passes through")
|
||||
assert_equal(#exec_calls, 0, "cookie holder must not be intercepted")
|
||||
end
|
||||
|
||||
local function test_browser_ua_root_without_cookie_intercepts()
|
||||
local runtime = load_runtime()
|
||||
local chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
reset_request(chrome, "/")
|
||||
runtime.check()
|
||||
assert_equal(#exec_calls, 1, "browser without cookie on / must be intercepted once")
|
||||
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "intercept targets the challenge page")
|
||||
assert_equal(redir_args.redir, "https://example.com/", "redir arg preserves scheme+host+uri")
|
||||
|
||||
reset_request(chrome, "/", nil, "a=1&b=2")
|
||||
runtime.check()
|
||||
assert_equal(#exec_calls, 1, "second request also intercepted")
|
||||
assert_equal(redir_args.redir, "https://example.com/?a=1&b=2", "redir arg keeps the query string")
|
||||
|
||||
reset_request("Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", "/")
|
||||
runtime.check()
|
||||
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Firefox intercepted")
|
||||
|
||||
reset_request(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
"/"
|
||||
)
|
||||
runtime.check()
|
||||
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Safari intercepted")
|
||||
end
|
||||
|
||||
local function test_browser_ua_non_root_passes_through()
|
||||
local runtime = load_runtime()
|
||||
reset_request(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"/about"
|
||||
)
|
||||
assert_equal(runtime.check(), true, "non-root uri passes through")
|
||||
assert_equal(#exec_calls, 0, "non-root uri must not be intercepted")
|
||||
end
|
||||
|
||||
local function run_challenge(redir_value)
|
||||
output.body = nil
|
||||
ngx.header = {}
|
||||
redir_args.redir = redir_value
|
||||
local chunk = assert(loadfile(challenge_path))
|
||||
chunk()
|
||||
return output.body
|
||||
end
|
||||
|
||||
local function test_challenge_embeds_plain_redir()
|
||||
local body = run_challenge("https://example.com/page?a=1&b=2")
|
||||
assert_equal(
|
||||
string.find(body, 'location.replace("https://example.com/page?a=1&b=2")', 1, true) ~= nil,
|
||||
true,
|
||||
"plain redir embedded verbatim"
|
||||
)
|
||||
end
|
||||
|
||||
local function test_challenge_escapes_script_breakout()
|
||||
local payload = '"/><script>alert(1)</script>'
|
||||
local body = run_challenge(payload)
|
||||
assert_equal(string.find(body, '"><script>', 1, true), nil, "raw breakout sequence must not appear")
|
||||
assert_equal(string.find(body, '\\x3C/script>', 1, true) ~= nil, true, "less-than must be hex-escaped")
|
||||
assert_equal(string.find(body, '\\"', 1, true) ~= nil, true, "double quote must be backslash-escaped")
|
||||
end
|
||||
|
||||
local function test_challenge_escapes_backslash_and_newline()
|
||||
local payload = 'a\\b";' .. string.char(13, 10)
|
||||
local body = run_challenge(payload)
|
||||
assert_equal(string.find(body, 'a\\\\b\\";\\r\\n', 1, true) ~= nil, true, "backslash, quote and CRLF escaped")
|
||||
end
|
||||
|
||||
test_module_contract()
|
||||
test_non_browser_ua_passes_through()
|
||||
test_browser_ua_non_get_passes_through()
|
||||
test_browser_ua_with_cookie_passes_through()
|
||||
test_browser_ua_root_without_cookie_intercepts()
|
||||
test_browser_ua_non_root_passes_through()
|
||||
test_challenge_embeds_plain_redir()
|
||||
test_challenge_escapes_script_breakout()
|
||||
test_challenge_escapes_backslash_and_newline()
|
||||
|
||||
return true
|
||||
@@ -538,6 +538,9 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
|
||||
appendIfChanged("OriginErrorPageStatusCodes", encodeOriginErrorPageStatusCodes(left.OriginErrorPageStatusCodes), encodeOriginErrorPageStatusCodes(right.OriginErrorPageStatusCodes))
|
||||
appendIfChanged("OriginErrorPageHTML", left.OriginErrorPageHTML, right.OriginErrorPageHTML)
|
||||
appendIfChanged("OriginErrorPageGetOnly", fmt.Sprintf("%t", left.OriginErrorPageGetOnly), fmt.Sprintf("%t", right.OriginErrorPageGetOnly))
|
||||
appendIfChanged("SWOfflineEnabled", fmt.Sprintf("%t", left.SWOfflineEnabled), fmt.Sprintf("%t", right.SWOfflineEnabled))
|
||||
appendIfChanged("SWOfflineHTML", left.SWOfflineHTML, right.SWOfflineHTML)
|
||||
appendIfChanged("SWOfflineDomains", strings.Join(left.SWOfflineDomains, ","), strings.Join(right.SWOfflineDomains, ","))
|
||||
return changes
|
||||
}
|
||||
|
||||
@@ -605,5 +608,8 @@ func openRestyOptionKeys() []string {
|
||||
"OriginErrorPageStatusCodes",
|
||||
"OriginErrorPageHTML",
|
||||
"OriginErrorPageGetOnly",
|
||||
"SWOfflineEnabled",
|
||||
"SWOfflineHTML",
|
||||
"SWOfflineDomains",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ type openRestyConfigSnapshot struct {
|
||||
OriginErrorPageStatusCodes []string `json:"origin_error_page_status_codes,omitempty"`
|
||||
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
|
||||
OriginErrorPageGetOnly bool `json:"origin_error_page_get_only,omitempty"`
|
||||
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
|
||||
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
}
|
||||
|
||||
type snapshotDocument struct {
|
||||
@@ -514,6 +517,18 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
return config.Value
|
||||
}
|
||||
|
||||
getStringSliceConfig := func(key string, defaultVal []string) []string {
|
||||
config, err := repository.GetSystemConfigByKey(ctx, key)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(config.Value), &values); err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
snapshot := openRestyConfigSnapshot{
|
||||
DefaultServerReturnStatus: getIntConfig(model.ConfigKeyOpenRestyDefaultServerReturnStatus, defaultOpenRestyReturnStatus),
|
||||
WorkerProcesses: getStringConfig(model.ConfigKeyOpenRestyWorkerProcesses, "auto"),
|
||||
@@ -560,6 +575,9 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
OriginErrorPageStatusCodes: parseOriginErrorPageStatusCodes(getStringConfig(model.ConfigKeyOriginErrorPageStatusCodes, `["500-599"]`)),
|
||||
OriginErrorPageHTML: getStringConfig(model.ConfigKeyOriginErrorPageHTML, ""),
|
||||
OriginErrorPageGetOnly: getBoolConfig(model.ConfigKeyOriginErrorPageGetOnly, false),
|
||||
SWOfflineEnabled: getBoolConfig(model.ConfigKeySWOfflineEnabled, false),
|
||||
SWOfflineHTML: getStringConfig(model.ConfigKeySWOfflineHTML, ""),
|
||||
SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil),
|
||||
}
|
||||
if snapshot.DefaultLimitRate == "0" {
|
||||
snapshot.DefaultLimitRate = ""
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
)
|
||||
|
||||
const maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||
const (
|
||||
maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||
maxSWOfflineDomains = 1000
|
||||
)
|
||||
|
||||
var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
model.ConfigKeyOpenRestyDefaultServerReturnStatus: validateOpenRestyDefaultServerReturnStatus,
|
||||
@@ -62,13 +65,16 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
model.ConfigKeyOriginErrorPageStatusCodes: validateOriginErrorPageStatusCodes,
|
||||
model.ConfigKeyOriginErrorPageHTML: validateOriginErrorPageHTML,
|
||||
model.ConfigKeyOriginErrorPageGetOnly: validateBooleanOption,
|
||||
model.ConfigKeySWOfflineEnabled: validateBooleanOption,
|
||||
model.ConfigKeySWOfflineHTML: validateSWOfflineHTML,
|
||||
model.ConfigKeySWOfflineDomains: validateSWOfflineDomains,
|
||||
}
|
||||
|
||||
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
|
||||
|
||||
func validateOpenRestyOption(key, value string) error {
|
||||
// HTML 按原始字节长度校验,避免 TrimSpace 影响上限判断
|
||||
if key == model.ConfigKeyOriginErrorPageHTML {
|
||||
if key == model.ConfigKeyOriginErrorPageHTML || key == model.ConfigKeySWOfflineHTML {
|
||||
return validateOriginErrorPageHTML(key, value)
|
||||
}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
@@ -247,3 +253,29 @@ func validateOriginErrorPageHTML(key, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSWOfflineHTML(key, value string) error {
|
||||
return validateOriginErrorPageHTML(key, value)
|
||||
}
|
||||
|
||||
func validateSWOfflineDomains(key, value string) error {
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(value), &domains); err != nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
}
|
||||
if len(domains) > maxSWOfflineDomains {
|
||||
return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(domains))
|
||||
for _, raw := range domains {
|
||||
domain := strings.ToLower(strings.TrimSpace(raw))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("%s 包含空域名", key)
|
||||
}
|
||||
if _, ok := seen[domain]; ok {
|
||||
return fmt.Errorf("%s 包含重复域名 %s", key, domain)
|
||||
}
|
||||
seen[domain] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package option
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -94,3 +95,31 @@ func TestValidateOriginErrorPageEnabled(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "true 或 false")
|
||||
}
|
||||
|
||||
func TestValidateSWOfflineDomains(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
ok bool
|
||||
}{
|
||||
{"empty array", `[]`, true},
|
||||
{"single", `["example.com"]`, true},
|
||||
{"multiple", `["example.com","api.example.com"]`, true},
|
||||
{"invalid json", `not-json`, false},
|
||||
{"empty element", `[""]`, false},
|
||||
{"duplicate", `["example.com","example.com"]`, false},
|
||||
{"whitespace dedup", `[" Example.com ","example.com"]`, false},
|
||||
{"over limit", fmt.Sprintf(`[%s]`, strings.Repeat(`"a.com",`, maxSWOfflineDomains)+`"a.com"`), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateSWOfflineDomains("sw_offline_domains", tc.value)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("want ok, got %v", err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user