From 5c87b283d1edb01ba7ff4fed2070ced1d1bd695c Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Tue, 22 Sep 2026 00:18:27 +0800 Subject: [PATCH 1/3] feat: configure password lengths and consume webpage query codes --- README.md | 16 +++++ api/accounts/handlers.go | 2 +- .../resource_password_integration_test.go | 40 +++++++++++ api/webprojects/content.go | 8 ++- app/siteconfig/service.go | 8 ++- app/siteconfig/session_policy_test.go | 27 ++++++++ config/nginx/web_projects_locations.conf | 4 ++ config/password_policy_test.go | 19 ++++++ config/registry.go | 4 +- config/runtime.go | 2 + deploy/pi/README.md | 8 +++ deploy/pi/gateway-api.conf | 13 ++++ deploy/pi/gateway-frontend.conf | 13 ++++ domain/service/accounts/rules.go | 2 +- domain/service/accounts/rules_test.go | 14 ++++ domain/service/fileshare/types.go | 7 +- domain/service/fileshare/validation_test.go | 29 ++++++++ domain/service/resourcepasswords/service.go | 3 +- front_vue/src/store/index.js | 2 + front_vue/src/utils/accounts_behavior.cjs | 2 +- front_vue/src/utils/file_sharing_behavior.cjs | 16 ++--- front_vue/src/views/AdminConfig.vue | 2 +- front_vue/src/views/FileShareManager.vue | 20 +++--- front_vue/src/views/FileShareReceive.vue | 2 +- front_vue/src/views/ManualEditor.vue | 5 +- front_vue/src/views/WebProjectEditor.vue | 5 +- front_vue/test/file-sharing-browser.cjs | 2 +- front_vue/test/password-policy.test.cjs | 68 +++++++++++++++++++ front_vue/test/password-query-browser.cjs | 39 +++++++++++ internal/accountsmigrate/flow.go | 2 + utils/log/request_uri.go | 6 +- utils/log/request_uri_test.go | 9 +++ 32 files changed, 361 insertions(+), 38 deletions(-) create mode 100644 config/password_policy_test.go create mode 100644 front_vue/test/password-policy.test.cjs create mode 100644 front_vue/test/password-query-browser.cjs diff --git a/README.md b/README.md index 90e00b5..7c40c7d 100644 --- a/README.md +++ b/README.md @@ -386,3 +386,19 @@ A: 本项目是家庭服务,内网只暴露了 http,外网通过一个虚拟 可选缓存覆盖书目、库位、已发布版本元数据及应用 Token 快照;权限和撤销信息保持实时检查。托管页面支持浏览器口径的每日/累计 PV、近似 UV,保存最近 90 日每日汇总与长期总量,编辑页可查看 7/30/90 日趋势。 新开关默认关闭。统计开启前需要手动执行增量统计表迁移,并配置独立私有访客密钥;应用不会修改 Redis 配置或自动建表。统计只使用普通 Redis 命令和 pipeline,不需要 Lua/EVAL 权限,也不要求特定淘汰策略。完整口径、启用配置、故障边界和回滚步骤见 [Redis 缓存与访问统计](docs/redis-cache-analytics.md)。 + +### 密码长度配置与网页快速解锁 + +管理员在“管理中心 → 通用配置 → 账号策略”中调整密码长度,校验并发布后可查看实际生效版本。 + +| 配置项 | 可配置范围 | 默认值 | 适用范围 | +| --- | --- | --- | --- | +| 账号密码最少字符数 | 4~64 个字符 | 15 | 注册、改密、管理员创建及重置密码 | +| 网页、说明书与文件分享密码最少字节数 | 4~72 个 UTF-8 字节 | 8 | 网页阅读密码、说明书阅读密码、要求登录的文件分享密码 | +| 公开文件分享码长度 | 4~12 位 | 6 | 新建公开文件分享的随机或自定义 ASCII 字母数字分享码 | + +ASCII 字符各占 1 字节,因此三类口令均可配置为 4 位。账号与分享密码最多 72 个 UTF-8 字节;账号自动生成的临时密码仍至少 20 位。配置只约束新建或修改操作,已有密码与分享码仍按原值验证。升级不会覆盖现有账号策略,也不自动降低默认长度。 + +网页阅读链接支持 `/p/report/?code=1234`。独立解锁页会立即从地址栏删除全部 `code` 参数,再通过原 POST 解锁接口验证密码;成功后打开原页面,保留其他查询参数和 `#` 定位。错误密码显示“密码不正确”,可直接手动重试。原始页面和增强页面均适用,已有解锁 Cookie 或所有者访问也会清除参数。链接不会绕过仅自己、指定用户等访问范围;文件分享和说明书的 URL 不使用此参数。 + +`code` 仍会出现在首次请求中,地址栏清除仅避免打开后复制时再次带出密码。服务端日志会对该参数脱敏;部署应同步更新网页代理日志配置,避免代理层记录原始请求。分享完整带码链接等于同时分享密码。 diff --git a/api/accounts/handlers.go b/api/accounts/handlers.go index 1f3ceed..a195785 100644 --- a/api/accounts/handlers.go +++ b/api/accounts/handlers.go @@ -247,7 +247,7 @@ func publicBootstrap(c *gin.Context) { return } runtime := config.Runtime() - respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled, "file_sharing": runtime.FileSharing.Enabled}}, nil) + respond(c, map[string]interface{}{"site": map[string]string{"title": runtime.SiteTitle, "notice": runtime.SiteNotice}, "registration": value, "share_password_policy": map[string]int{"min_length": runtime.AccountPolicy.MinSharePasswordLength, "code_length": runtime.AccountPolicy.ShareCodeLength}, "modules": map[string]bool{"manuals": runtime.Manuals.Enabled, "file_sharing": runtime.FileSharing.Enabled}}, nil) } // validateInvitation 处理 POST /api/auth/invitations/validate:在来源限流后校验邀请码,仅返回有效状态与到期时间,不消费邀请码。 diff --git a/api/accounts/resource_password_integration_test.go b/api/accounts/resource_password_integration_test.go index 64c4992..7e09242 100644 --- a/api/accounts/resource_password_integration_test.go +++ b/api/accounts/resource_password_integration_test.go @@ -123,3 +123,43 @@ func passwordGrantCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie { t.Fatal("unlock did not issue a resource grant cookie") return nil } + +func TestHTTPFourBytePolicyAndQueryPasswordGate(t *testing.T) { + f := newHTTPFixture(t) + owner := f.login(f.owner, integrationPassword) + current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", owner, nil, nil)) + values := object(current["values"]) + values["min_password_length"], values["min_share_password_length"], values["share_code_length"] = 4, 4, 4 + requireSuccess(t, f.publish(owner, "account_policy", values, "four-byte-password-policy")) + bootstrap := requireSuccess(t, f.request("GET", "/api/site/bootstrap", nil, nil, nil)) + if number(object(bootstrap["share_password_policy"])["min_length"]) != 4 || number(object(bootstrap["registration"])["min_password_length"]) != 4 { + t.Fatal("bootstrap omitted active password lengths") + } + project, _ := f.privatePage(owner) + id := project["id"].(string) + base := "/api/web-share/" + id + queryPath := "/p/http-private-fixture/index.html?code=1234&view=wide" + if response := f.request("GET", queryPath, nil, nil, nil); response.Status != http.StatusNotFound { + t.Fatal("query password bypassed private ACL") + } + requireSuccess(t, f.request("PATCH", base, owner, map[string]interface{}{"access_mode": "public", "container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)})) + if response := f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "123", "version": 0}, nil); response.Status != http.StatusBadRequest { + t.Fatal("three-byte password accepted") + } + requireSuccess(t, f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "1234", "version": 0}, nil)) + unlocked := f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "1234"}, nil) + requireSuccess(t, unlocked) + grant := passwordGrantCookie(t, unlocked.Cookies) + for _, headers := range []map[string]string{nil, {"Cookie": grant.Name + "=" + grant.Value}, {"Cookie": owner.Cookie.Name + "=" + owner.Cookie.Value}} { + page := f.request("GET", queryPath, nil, nil, headers) + if page.Status != http.StatusForbidden || !strings.Contains(string(page.Raw), "history.replaceState") || strings.Contains(string(page.Raw), "code=1234") { + t.Fatalf("query did not use isolated gate: %d", page.Status) + } + } + values["min_share_password_length"] = 8 + requireSuccess(t, f.publish(owner, "account_policy", values, "restore-share-password-policy")) + requireSuccess(t, f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "1234"}, nil)) + if bad := f.request("POST", base+"/unlock", nil, map[string]interface{}{"password": "4321"}, nil); bad.Status != http.StatusForbidden { + t.Fatal("incorrect password accepted") + } +} diff --git a/api/webprojects/content.go b/api/webprojects/content.go index c93b865..60b25fa 100644 --- a/api/webprojects/content.go +++ b/api/webprojects/content.go @@ -79,6 +79,12 @@ func serveProjectContent(c *gin.Context) { if requested == "" { requested = release.EntryFile } + // Consume query passwords in an isolated page even for owners or existing grants, + // before any hosted script or resource can observe the original URL. + if _, hasCode := c.Request.URL.Query()["code"]; hasCode && c.Request.Method == http.MethodGet && passwordHTMLRequest(c, requested) { + servePasswordUnlockPage(c, project.ID) + return + } _, passwordErr := resourcepasswords.Default.Authorize(ginfmt.RPCContext(c), resourcepasswords.ResourceWebProject, project.ID, principal != nil && principal.UserID == project.OwnerUserID, resourcepasswords.GrantToken(c.Request, resourcepasswords.ResourceWebProject, project.ID)) if passwordErr != nil { c.Header("X-Resource-Password", "required") @@ -162,7 +168,7 @@ func passwordHTMLRequest(c *gin.Context, requested string) bool { func servePasswordUnlockPage(c *gin.Context, projectID int64) { endpoint := "/api/web-share/" + strconv.FormatInt(projectID, 10) + "/unlock" - html := `密码阅读

此网页受密码保护

输入分享者提供的阅读密码。

` + html := `密码阅读

此网页受密码保护

输入分享者提供的阅读密码。

` c.Header("Cache-Control", "no-store") c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") c.Header("Referrer-Policy", "no-referrer") diff --git a/app/siteconfig/service.go b/app/siteconfig/service.go index 63bd2ba..5a9acb7 100644 --- a/app/siteconfig/service.go +++ b/app/siteconfig/service.go @@ -156,10 +156,12 @@ func (s *Service) checkedValues(row model.SiteConfigCurrent) (map[string]interfa if err != nil { return nil, appErrors.ErrDependency } - // Published v1 account policies omit this additive key; preserve their original JSON and checksum. + // Published v1 policies may omit additive keys; preserve their original JSON and checksum. if row.Namespace == "account_policy" && values != nil { - if _, exists := values["max_active_sessions"]; !exists { - values["max_active_sessions"] = 5 + for key, fallback := range map[string]int{"max_active_sessions": 5, "min_share_password_length": 8, "share_code_length": 6} { + if _, exists := values[key]; !exists { + values[key] = fallback + } } } normalized, err := config.ValidateValues(s.bootstrap, row.Namespace, values) diff --git a/app/siteconfig/session_policy_test.go b/app/siteconfig/session_policy_test.go index f0c48a4..f6cc179 100644 --- a/app/siteconfig/session_policy_test.go +++ b/app/siteconfig/session_policy_test.go @@ -107,3 +107,30 @@ func TestStoredSessionPolicyIsReadOnlyAndLegacyRollbackAddsDefault(t *testing.T) t.Fatal("legacy rollback rewrote historical JSON or checksum") } } + +func TestStoredLegacyPasswordPolicyKeepsOriginalDocument(t *testing.T) { + service := New(nil, config.Config{}) + values := config.DefaultRuntimeValues(config.Config{})["account_policy"] + delete(values, "min_share_password_length") + delete(values, "share_code_length") + raw, _ := json.Marshal(values) + hash := sha256.Sum256(raw) + row := model.SiteConfigCurrent{Namespace: "account_policy", Revision: 1, SchemaVersion: 1, ValuesJSON: string(raw), ValuesSHA256: hash[:]} + actual, err := service.checkedValues(row) + if err != nil || actual["min_share_password_length"] != int64(8) || actual["share_code_length"] != int64(6) { + t.Fatalf("legacy defaults: %v %v", actual, err) + } + if row.ValuesJSON != string(raw) || !bytes.Equal(row.ValuesSHA256, hash[:]) { + t.Fatal("legacy stored policy changed") + } + for _, key := range []string{"min_share_password_length", "share_code_length"} { + values[key] = 3 + raw, _ = json.Marshal(values) + hash = sha256.Sum256(raw) + row.ValuesJSON, row.ValuesSHA256 = string(raw), hash[:] + if _, err := service.checkedValues(row); err == nil { + t.Fatalf("invalid stored %s accepted", key) + } + delete(values, key) + } +} diff --git a/config/nginx/web_projects_locations.conf b/config/nginx/web_projects_locations.conf index 5004033..13102a8 100644 --- a/config/nginx/web_projects_locations.conf +++ b/config/nginx/web_projects_locations.conf @@ -59,6 +59,10 @@ location ^~ /api/web-projects/ { } location ^~ /p/ { + # Query passwords must not appear in access or upstream error logs. + access_log off; + error_log /dev/null; + add_header Referrer-Policy "no-referrer" always; proxy_pass http://127.0.0.1:18080; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/config/password_policy_test.go b/config/password_policy_test.go new file mode 100644 index 0000000..7b2f6ae --- /dev/null +++ b/config/password_policy_test.go @@ -0,0 +1,19 @@ +package config + +import "testing" + +func TestConfigurablePasswordBoundaries(t *testing.T) { + for _, key := range []string{"min_password_length", "min_share_password_length", "share_code_length"} { + values := DefaultRuntimeValues(Config{}) + values["account_policy"][key] = 4 + if _, err := BuildRuntimeSnapshot(Config{}, 0, nil, values); err != nil { + t.Fatalf("%s=4: %v", key, err) + } + for _, invalid := range []interface{}{3, 0, nil, 4.5, 73} { + values["account_policy"][key] = invalid + if _, err := ValidateValues(Config{}, "account_policy", values["account_policy"]); err == nil { + t.Fatalf("accepted %s=%v", key, invalid) + } + } + } +} diff --git a/config/registry.go b/config/registry.go index 0eeaf8f..3d2107e 100644 --- a/config/registry.go +++ b/config/registry.go @@ -86,7 +86,9 @@ func Registry(conf Config) []NamespaceSchema { {"account_policy", "session_ttl_seconds", "用户会话有效期", "秒", 300, 2592000, 604800}, {"account_policy", "max_active_sessions", "每个账号同时有效的网站登录上限", "个会话", 1, 100, 5}, {"account_policy", "temporary_password_ttl_days", "临时密码有效期", "天", 1, 30, 7}, - {"account_policy", "min_password_length", "密码最少字符数", "字符", 15, 64, 15}, + {"account_policy", "min_password_length", "账号密码最少字符数", "字符", 4, 64, 15}, + {"account_policy", "min_share_password_length", "网页、说明书与文件分享密码最少字节数", "UTF-8 字节", 4, 72, 8}, + {"account_policy", "share_code_length", "公开文件分享码长度", "位", 4, 12, 6}, {"account_policy", "bcrypt_cost", "密码哈希成本", "", 10, 14, 12}, } { min, max := limit.min, limit.max diff --git a/config/runtime.go b/config/runtime.go index b59520e..5fadddc 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -8,6 +8,8 @@ import ( ) type AccountPolicyConfig struct { + MinSharePasswordLength int `json:"min_share_password_length"` + ShareCodeLength int `json:"share_code_length"` SessionTTLSeconds int `json:"session_ttl_seconds"` MaxActiveSessions int `json:"max_active_sessions"` TemporaryPasswordTTLDays int `json:"temporary_password_ttl_days"` diff --git a/deploy/pi/README.md b/deploy/pi/README.md index ea1f29e..0d0c492 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -115,3 +115,11 @@ sudo systemctl enable home_server.target home_server.service home_server_fronten 下载计数表示成功授权并开始传输的次数,客户端断线不会恢复额度。程序回滚时保留四张新增表及私有文件,先核对下一段的密码兼容性条件;不得把数据库快照覆盖到已经产生新业务数据的库。 回滚密码阅读后端前必须检查 `resource_passwords`:旧版本不识别新密码。如果已有非空密码,优先仅回滚前端;回滚后端前先停止相关内容对外访问,不能让密码资源退化为仅原ACL保护。自动回滚只有在确认尚无密码记录时才允许切回旧后端;无法核实则停止服务并保留新release与备份,等待恢复。 + +## 可配置密码长度升级 + +本版本没有新增表或 DDL。旧 `account_policy` 缺少 `min_share_password_length` 与 `share_code_length` 时,仅在读取时补充 8、6,保留原 JSON、校验和及历史版本;新发布配置须包含完整字段。账号密码下限允许设为 4,已有值保持不变。发布配置后,旧程序不识别新增字段,不能直接切回旧后端;应部署兼容新字段的修复版本;管理页面回滚历史配置也会补齐新增字段,不能据此恢复旧程序。旧版本严格校验配置字段,单纯恢复前端不影响后端配置。 + +前后端须使用同一源码提交构建。更新 `/etc/home_server/locations/web_projects_locations.conf`,沿用现有可信代理变量;同步将两个 TLS 网关模板的 `/p/` location 配置应用到实际站点,关闭该路径访问日志与错误日志,增加 `Referrer-Policy: no-referrer`。分别运行 Nginx 配置检查再 reload。保留旧 release 和配置副本用于未发布新策略前的程序回滚。 + +验收应覆盖:网页配置下限为 4、3 位口令拒绝、4 位口令成功、提高下限后旧密码仍有效;带码链接正确及错误两种情况、地址栏删除所有 code 参数、保留其他参数与片段、原有 Cookie/所有者场景以及仅自己可见的权限约束。 diff --git a/deploy/pi/gateway-api.conf b/deploy/pi/gateway-api.conf index 75fb0a9..5d02eef 100644 --- a/deploy/pi/gateway-api.conf +++ b/deploy/pi/gateway-api.conf @@ -143,6 +143,19 @@ server { proxy_intercept_errors off; } + # Query passwords are consumed by the backend password gate. + location ^~ /p/ { + access_log off; + error_log /dev/null; + proxy_pass http://192.0.2.10:18080; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache off; + proxy_intercept_errors off; + add_header Referrer-Policy "no-referrer" always; + } + location / { proxy_pass http://192.0.2.10:18080; proxy_http_version 1.1; diff --git a/deploy/pi/gateway-frontend.conf b/deploy/pi/gateway-frontend.conf index 9ac98f2..9aff183 100644 --- a/deploy/pi/gateway-frontend.conf +++ b/deploy/pi/gateway-frontend.conf @@ -149,6 +149,19 @@ server { proxy_intercept_errors off; } + # Query passwords are consumed by the backend password gate. + location ^~ /p/ { + access_log off; + error_log /dev/null; + proxy_pass http://192.0.2.10:18081; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache off; + proxy_intercept_errors off; + add_header Referrer-Policy "no-referrer" always; + } + location / { proxy_pass http://192.0.2.10:18081; proxy_http_version 1.1; diff --git a/domain/service/accounts/rules.go b/domain/service/accounts/rules.go index 54703b3..403407b 100644 --- a/domain/service/accounts/rules.go +++ b/domain/service/accounts/rules.go @@ -19,7 +19,7 @@ var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) var invitationLocation = time.FixedZone("Asia/Singapore", 8*60*60) func ValidatePassword(password, confirmation string, minimum int) error { - if minimum < 15 { + if minimum < 4 { minimum = 15 } if !utf8.ValidString(password) || utf8.RuneCountInString(password) < minimum || len(password) > 72 || strings.ContainsRune(password, '\x00') { diff --git a/domain/service/accounts/rules_test.go b/domain/service/accounts/rules_test.go index 0d65e88..c0ddb72 100644 --- a/domain/service/accounts/rules_test.go +++ b/domain/service/accounts/rules_test.go @@ -43,3 +43,17 @@ func TestWebDAVPermissionChecksEveryReadAndWriteMethod(t *testing.T) { } } } + +func TestFourCharacterPasswordPolicy(t *testing.T) { + for _, password := range []string{"1234", "四个字符"} { + if err := ValidatePassword(password, password, 4); err != nil { + t.Fatal(err) + } + } + if err := ValidatePassword("123", "123", 4); err == nil { + t.Fatal("three-character password accepted") + } + if err := ValidatePassword("1234", "1234", 15); err == nil { + t.Fatal("default policy was relaxed") + } +} diff --git a/domain/service/fileshare/types.go b/domain/service/fileshare/types.go index dfd7169..d60f59a 100644 --- a/domain/service/fileshare/types.go +++ b/domain/service/fileshare/types.go @@ -12,6 +12,7 @@ import ( "time" "unicode/utf8" + "github.com/mcoder2014/home_server/config" "github.com/mcoder2014/home_server/domain/model" apperrors "github.com/mcoder2014/home_server/errors" ) @@ -167,7 +168,7 @@ func NormalizeShareInput(input CreateShareInput, random io.Reader, now time.Time } func ValidCode(value string) bool { - if len(value) != 6 { + if len(value) != config.Runtime().AccountPolicy.ShareCodeLength { return false } for _, character := range []byte(value) { @@ -183,7 +184,7 @@ func ValidCode(value string) bool { } func validPassword(value string) bool { - return utf8.ValidString(value) && len([]byte(value)) >= 8 && len([]byte(value)) <= 72 + return utf8.ValidString(value) && len([]byte(value)) >= config.Runtime().AccountPolicy.MinSharePasswordLength && len([]byte(value)) <= 72 } func randomCode(source io.Reader) (string, error) { @@ -191,7 +192,7 @@ func randomCode(source io.Reader) (string, error) { source = rand.Reader } const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" - raw := make([]byte, 6) + raw := make([]byte, config.Runtime().AccountPolicy.ShareCodeLength) if _, err := io.ReadFull(source, raw); err != nil { return "", err } diff --git a/domain/service/fileshare/validation_test.go b/domain/service/fileshare/validation_test.go index c97e0be..bd09dc6 100644 --- a/domain/service/fileshare/validation_test.go +++ b/domain/service/fileshare/validation_test.go @@ -1,6 +1,7 @@ package fileshare import ( + "github.com/mcoder2014/home_server/config" "strings" "testing" "time" @@ -79,3 +80,31 @@ func TestPublicStateSeparatesLoginAndSecretWithoutLeakingMembership(t *testing.T } func timePointer(value time.Time) *time.Time { return &value } + +func TestFourByteFileShareSecrets(t *testing.T) { + original := config.Runtime() + t.Cleanup(func() { + if err := config.StoreRuntimeSnapshot(original); err != nil { + t.Fatal(err) + } + }) + policy := original + policy.AccountPolicy.MinSharePasswordLength = 4 + policy.AccountPolicy.ShareCodeLength = 4 + if err := config.StoreRuntimeSnapshot(policy); err != nil { + t.Fatal(err) + } + for _, input := range []CreateShareInput{ + {AccessMode: "public", SecretMode: "code", Secret: "aB12"}, + {AccessMode: "public", SecretMode: "code"}, + {AccessMode: "authenticated", SecretMode: "password", Secret: "1234"}, + } { + result, err := NormalizeShareInput(input, strings.NewReader("synthetic-random"), time.Now()) + if err != nil || len(result.Secret) != 4 { + t.Fatalf("four-byte secret failed: %v %v", result, err) + } + } + if _, err := NormalizeShareInput(CreateShareInput{AccessMode: "authenticated", SecretMode: "password", Secret: "123"}, nil, time.Now()); err == nil { + t.Fatal("three-byte password accepted") + } +} diff --git a/domain/service/resourcepasswords/service.go b/domain/service/resourcepasswords/service.go index ca01814..10170ea 100644 --- a/domain/service/resourcepasswords/service.go +++ b/domain/service/resourcepasswords/service.go @@ -17,6 +17,7 @@ import ( "time" "unicode/utf8" + "github.com/mcoder2014/home_server/config" "github.com/mcoder2014/home_server/domain/dal" "github.com/mcoder2014/home_server/domain/db" "github.com/mcoder2014/home_server/domain/model" @@ -95,7 +96,7 @@ func ValidateManagedPassword(password string) error { if password == "" { return nil } - if !utf8.ValidString(password) || len([]byte(password)) < 8 || len([]byte(password)) > 72 { + if !utf8.ValidString(password) || len([]byte(password)) < config.Runtime().AccountPolicy.MinSharePasswordLength || len([]byte(password)) > 72 { return apperrors.ErrInvalid } return nil diff --git a/front_vue/src/store/index.js b/front_vue/src/store/index.js index b1f4f6b..0295b6d 100644 --- a/front_vue/src/store/index.js +++ b/front_vue/src/store/index.js @@ -19,6 +19,7 @@ const store = createStore({ sessionLoaded: false, sessionEpoch: 0, site: {title: 'CQ Home Server', notice: ''}, + sharePasswordPolicy: {min_length: 8, code_length: 6}, registration: {enabled: false, monthly_limit: 3, ttl_days: 7}, modules: {manuals: false, file_sharing: false}, bootstrapLoaded: false, @@ -35,6 +36,7 @@ const store = createStore({ localStorage.setItem('isbn', isbn) }, SET_BOOTSTRAP(state, value) { + state.sharePasswordPolicy = value.share_password_policy || state.sharePasswordPolicy state.site = value.site || state.site state.registration = value.registration || state.registration state.modules = value.modules || state.modules diff --git a/front_vue/src/utils/accounts_behavior.cjs b/front_vue/src/utils/accounts_behavior.cjs index ce842c4..4a8946f 100644 --- a/front_vue/src/utils/accounts_behavior.cjs +++ b/front_vue/src/utils/accounts_behavior.cjs @@ -23,7 +23,7 @@ function routeDecision(route, user, modules = {}) { } function passwordError(password, confirmation, minimum = 15) { - minimum = Math.max(15, minimum) + minimum = Math.max(4, minimum) if ((password || '').includes('\u0000')) return '密码包含无效字符' if (Array.from(password || '').length < minimum) return `密码至少需要 ${minimum} 个字符,允许空格和粘贴` if (new TextEncoder().encode(password).length > 72) return '密码不能超过 72 个 UTF-8 字节(中文通常占 3 字节)' diff --git a/front_vue/src/utils/file_sharing_behavior.cjs b/front_vue/src/utils/file_sharing_behavior.cjs index fcf2442..8d98283 100644 --- a/front_vue/src/utils/file_sharing_behavior.cjs +++ b/front_vue/src/utils/file_sharing_behavior.cjs @@ -13,31 +13,31 @@ function uploadFileError(file) { return '' } -function secretError(mode, value) { +function secretError(mode, value, minimum = 8, codeLength = 6) { const secret = String(value || '') if (mode === 'none') return '' - if (mode === 'code') return /^[A-Za-z0-9]{6}$/.test(secret) ? '' : '分享码必须是 6 位 ASCII 字母或数字,区分大小写' + if (mode === 'code') return /^[A-Za-z0-9]+$/.test(secret) && secret.length === codeLength ? '' : `分享码必须是 ${codeLength} 位 ASCII 字母或数字,区分大小写` if (mode !== 'password') return '请选择有效的口令方式' const bytes = utf8Length(secret) - return bytes >= 8 && bytes <= 72 ? '' : '登录分享密码必须为 8~72 个 UTF-8 字节' + return bytes >= minimum && bytes <= 72 ? '' : `登录分享密码必须为 ${minimum}~72 个 UTF-8 字节` } -function resourcePasswordError(password, confirmation) { +function resourcePasswordError(password, confirmation, minimum = 8) { const bytes = utf8Length(password) - if (bytes < 8 || bytes > 72) return '阅读密码必须为 8~72 个 UTF-8 字节' + if (bytes < minimum || bytes > 72) return `阅读密码必须为 ${minimum}~72 个 UTF-8 字节` if (password !== confirmation) return '两次输入的阅读密码不一致' return '' } -function randomShareCode(fillRandom = array => crypto.getRandomValues(array)) { +function randomShareCode(fillRandom = array => crypto.getRandomValues(array), length = 6) { let code = '' - while (code.length < 6) { + while (code.length < length) { const bytes = new Uint8Array(12) fillRandom(bytes) for (const byte of bytes) { if (byte >= 248) continue code += CODE_CHARACTERS[byte % CODE_CHARACTERS.length] - if (code.length === 6) break + if (code.length === length) break } } return code diff --git a/front_vue/src/views/AdminConfig.vue b/front_vue/src/views/AdminConfig.vue index 51e943b..30da7b6 100644 --- a/front_vue/src/views/AdminConfig.vue +++ b/front_vue/src/views/AdminConfig.vue @@ -25,7 +25,7 @@

关闭注册后,不能生成或兑换邀请码,现有未使用邀请码永久失效。重新开启、版本回滚均不会复活旧邀请码;管理员仍可手动添加用户。

全站开关与个人授权共同生效。WebDAV 使用共享根目录,打开站点能力不会自动授权任何账号。

全站开关与个人授权共同生效,打开站点能力不会自动授予个人藏书权限。

-

网站登录上限按有效会话计数,不代表物理设备数。达到上限会拒绝新的登录;降低上限不会主动退出已有会话,超额会话保留至主动退出或过期。

+

密码长度仅约束新建或修改密码,已有密码和分享码仍可使用。账号密码按字符计数,分享密码按 UTF-8 字节计数(ASCII 字符各占 1 字节),上限均受 72 字节限制。网站登录上限按有效会话计数,不代表物理设备数。达到上限会拒绝新的登录;降低上限不会主动退出已有会话,超额会话保留至主动退出或过期。

校验并查看差异撤销草稿
diff --git a/front_vue/src/views/FileShareManager.vue b/front_vue/src/views/FileShareManager.vue index 403dd21..f59ef16 100644 --- a/front_vue/src/views/FileShareManager.vue +++ b/front_vue/src/views/FileShareManager.vue @@ -71,14 +71,14 @@
额外口令 - - + +
- +
- +
@@ -135,12 +135,16 @@ export default { files: [], filesCursor: '', filesHasMore: false, loadingFiles: false, pendingFile: null, uploading: false, uploadProgress: 0, uploadError: '', pageError: '', selectedFile: null, shares: [], sharesCursor: '', sharesHasMore: false, loadingShares: false, creatingShare: false, savingShare: false, shareError: '', shareForm: emptyShareForm(), createdShare: null, eligibleUsers: [], accessOptions: [ - {value: 'public', label: '任何拿到链接的人', description: '无需登录,可选择 6 位分享码'}, + {value: 'public', label: '任何拿到链接的人', description: '无需登录,可选择分享码'}, {value: 'authenticated', label: '本站登录用户', description: '有效账号登录后可以下载'}, {value: 'members', label: '指定成员', description: '只允许选中的本站账号'}, ], } }, + computed: { + sharePasswordMinimum() { return this.$store.state.sharePasswordPolicy?.min_length || 8 }, + shareCodeLength() { return this.$store.state.sharePasswordPolicy?.code_length || 6 }, + }, async created() { if (!this.$store.state.userInfo) { this.$router.replace({path: '/login', query: {redirect: this.$route.fullPath}}) @@ -206,10 +210,10 @@ export default { this.shareForm.code_source = 'random'; this.generateCode() }, generateCode() { - this.shareForm.secret = randomShareCode() + this.shareForm.secret = randomShareCode(undefined, this.shareCodeLength) }, sharePayload() { - const secretValidation = secretError(this.shareForm.secret_mode, this.shareForm.secret) + const secretValidation = secretError(this.shareForm.secret_mode, this.shareForm.secret, this.sharePasswordMinimum, this.shareCodeLength) if (secretValidation) throw new Error(secretValidation) if (this.shareForm.access_mode === 'members' && this.shareForm.member_user_ids.length === 0) throw new Error('请至少选择一位成员') const maxDownloads = this.shareForm.download_limit === 'unlimited' ? 0 : this.shareForm.download_limit === 'once' ? 1 : Number(this.shareForm.max_downloads) @@ -263,7 +267,7 @@ export default { shareState(share) { return fileShareState(share) }, absoluteShareURL(share) { return new URL(share.url || `/s/${share.token}`, window.location.origin).href }, accessText(mode) { return {public: '任何人', authenticated: '登录用户', members: '指定成员'}[mode] || mode }, - secretText(mode) { return {none: '无额外口令', code: '6 位分享码', password: '分享密码'}[mode] || mode }, + secretText(mode) { return {none: '无额外口令', code: '分享码', password: '分享密码'}[mode] || mode }, formatDate(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? '未知时间' : date.toLocaleString('zh-CN', {hour12: false}) }, formatBytes(bytes) { const value = Number(bytes || 0); return value < 1024 ? `${value} B` : value < 1048576 ? `${(value / 1024).toFixed(1)} KiB` : `${(value / 1048576).toFixed(1)} MiB` }, fileExtension(name) { const part = String(name || '').split('.').pop(); return part && part !== name ? part.slice(0, 4).toUpperCase() : 'FILE' }, diff --git a/front_vue/src/views/FileShareReceive.vue b/front_vue/src/views/FileShareReceive.vue index 19825d4..a499a1e 100644 --- a/front_vue/src/views/FileShareReceive.vue +++ b/front_vue/src/views/FileShareReceive.vue @@ -11,7 +11,7 @@
需要{{ share.secret_mode === 'code' ? '分享码' : '分享密码' }}

口令由分享者提供,不是你的账号密码。

- + 解锁文件 diff --git a/front_vue/src/views/ManualEditor.vue b/front_vue/src/views/ManualEditor.vue index 8bcce19..c602e9a 100644 --- a/front_vue/src/views/ManualEditor.vue +++ b/front_vue/src/views/ManualEditor.vue @@ -26,7 +26,7 @@

阅读密码

密码独立于可见范围;所有者仍可直接查看和管理。

{{ passwordState.password_protected ? '已启用阅读密码' : '尚未设置阅读密码' }}
- +
@@ -132,6 +132,7 @@ export default { } }, computed: { + sharePasswordMinimum() { return this.$store.state.sharePasswordPolicy?.min_length || 8 }, manualID() { return this.manual?.id || this.$route.params.id || '' }, totalItemCount() { return (this.manual?.items?.length || 0) + this.queue.filter(item => item.status !== 'success').length }, hasFailures() { return this.queue.some(item => item.status === 'failed') }, @@ -181,7 +182,7 @@ export default { }, async saveManualPassword() { if (this.passwordSaving) return - const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation) + const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation, this.sharePasswordMinimum) if (validation) { this.passwordNotice = validation; this.passwordNoticeType = 'error'; return } await this.updateManualPassword(this.passwordDraft) }, diff --git a/front_vue/src/views/WebProjectEditor.vue b/front_vue/src/views/WebProjectEditor.vue index bd3bb47..fb2437b 100644 --- a/front_vue/src/views/WebProjectEditor.vue +++ b/front_vue/src/views/WebProjectEditor.vue @@ -91,7 +91,7 @@ {{ passwordState.password_protected ? '已启用' : '未设置' }}
- +
@@ -264,6 +264,7 @@ export default { } }, computed: { + sharePasswordMinimum() { return this.$store.state.sharePasswordPolicy?.min_length || 8 }, isCreate() { return this.$route.name === 'WebShareCreate' }, @@ -335,7 +336,7 @@ export default { }, async saveProjectPassword() { if (this.passwordSaving) return - const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation) + const validation = resourcePasswordError(this.passwordDraft, this.passwordConfirmation, this.sharePasswordMinimum) if (validation) { this.passwordNotice = validation; this.passwordNoticeType = 'error'; return } await this.updateProjectPassword(this.passwordDraft) }, diff --git a/front_vue/test/file-sharing-browser.cjs b/front_vue/test/file-sharing-browser.cjs index ff9a57f..2122c40 100644 --- a/front_vue/test/file-sharing-browser.cjs +++ b/front_vue/test/file-sharing-browser.cjs @@ -215,7 +215,7 @@ test('收件页在1440与320展示通用口令门禁,解锁后连点只发送 await page.setViewportSize({width: 320, height: 820}) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) await page.screenshot({path: path.join(visualOutput, 'file-receive-password-mobile-320.png'), fullPage: true}) - await page.getByLabel('6 位分享码').fill('aB09zZ') + await page.getByLabel('分享码', {exact: true}).fill('aB09zZ') await page.getByRole('button', {name: '解锁文件', exact: true}).click() await page.getByRole('button', {name: '下载文件', exact: true}).waitFor() const downloadPromise = page.waitForEvent('download') diff --git a/front_vue/test/password-policy.test.cjs b/front_vue/test/password-policy.test.cjs new file mode 100644 index 0000000..7196e27 --- /dev/null +++ b/front_vue/test/password-policy.test.cjs @@ -0,0 +1,68 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const {passwordError} = require('../src/utils/accounts_behavior.cjs') +const {secretError, resourcePasswordError, randomShareCode} = require('../src/utils/file_sharing_behavior.cjs') + +test('configured four-character account and sharing secrets retain upper bounds and confirmation', () => { + assert.equal(passwordError('1234', '1234', 4), '') + assert.match(passwordError('123', '123', 4), /4/) + assert.match(passwordError('1234', '4321', 4), /不一致/) + assert.match(passwordError('😀'.repeat(19), '😀'.repeat(19), 4), /72/) + assert.equal(secretError('password', '1234', 4), '') + assert.equal(resourcePasswordError('1234', '1234', 4), '') + assert.match(resourcePasswordError('123', '123', 4), /4/) + assert.equal(secretError('code', 'aB12', 4, 4), '') + assert.match(secretError('code', 'aB1234', 4, 4), /4 位/) + for (const length of [4, 6, 12]) assert.equal(randomShareCode(a => a.fill(1), length).length, length) + assert.match(resourcePasswordError('1234', '1234'), /8/) +}) + +// Execute both scripts from the actual Go-served gate in document order. +function gate(query, responseStatus = 200) { + const source = fs.readFileSync(path.join(__dirname, '../../api/webprojects/content.go'), 'utf8') + const html = source.match(/html := `([\s\S]*?)`\n/)[1].replace('` + endpoint + `', '/api/web-share/42/unlock') + const scripts = Array.from(html.matchAll(/

此网页受密码保护

输入分享者提供的阅读密码。

` + html := `密码阅读

此网页受密码保护

输入分享者提供的阅读密码。

` c.Header("Cache-Control", "no-store") c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") c.Header("Referrer-Policy", "no-referrer") diff --git a/front_vue/test/password-policy.test.cjs b/front_vue/test/password-policy.test.cjs index 7196e27..96d4989 100644 --- a/front_vue/test/password-policy.test.cjs +++ b/front_vue/test/password-policy.test.cjs @@ -29,7 +29,7 @@ function gate(query, responseStatus = 200) { const requests = [], redirects = [], nodes = {unlock: {addEventListener(_, fn) { this.submit = fn }}, password: {value: ''}, submit: {}, error: {}} const context = vm.createContext({URL, document: {getElementById: id => nodes[id]}, history: {state: {keep: true}, replaceState(state, _, value) { assert.equal(state.keep, true); href = new URL(value, href).href }}, - location: {get href() { return href }, replace(value) { redirects.push(value) }}, + location: {get href() { return href }, reload() { const url = new URL(href); redirects.push(url.pathname + url.search + url.hash) }}, fetch: async (url, options) => { requests.push({url, options, href}); return {ok: responseStatus === 200, status: responseStatus} }, }) scripts.forEach(script => vm.runInContext(script, context)) From 08ddbec8f4c9b1816c3b1a8b432d328b47f10c17 Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Tue, 22 Sep 2026 00:24:51 +0800 Subject: [PATCH 3/3] fix: preserve legacy policy rollback and scrub unavailable page codes --- api/accounts/resource_password_integration_test.go | 4 ++-- api/webprojects/container.go | 3 ++- front_vue/src/views/AdminConfig.vue | 8 +++++--- front_vue/test/accounts-pages.test.cjs | 9 ++++++--- front_vue/test/password-query-browser.cjs | 12 ++++++++++++ 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/api/accounts/resource_password_integration_test.go b/api/accounts/resource_password_integration_test.go index 7e09242..59e01d2 100644 --- a/api/accounts/resource_password_integration_test.go +++ b/api/accounts/resource_password_integration_test.go @@ -139,8 +139,8 @@ func TestHTTPFourBytePolicyAndQueryPasswordGate(t *testing.T) { id := project["id"].(string) base := "/api/web-share/" + id queryPath := "/p/http-private-fixture/index.html?code=1234&view=wide" - if response := f.request("GET", queryPath, nil, nil, nil); response.Status != http.StatusNotFound { - t.Fatal("query password bypassed private ACL") + if response := f.request("GET", queryPath, nil, nil, map[string]string{"Accept": "text/html"}); response.Status != http.StatusNotFound || !strings.Contains(string(response.Raw), "history.replaceState") || response.Header.Get("Referrer-Policy") != "no-referrer" { + t.Fatal("private page must retain 404 while clearing the query password") } requireSuccess(t, f.request("PATCH", base, owner, map[string]interface{}{"access_mode": "public", "container_mode": "raw"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)})) if response := f.request("PUT", base+"/password", owner, map[string]interface{}{"password": "123", "version": 0}, nil); response.Status != http.StatusBadRequest { diff --git a/api/webprojects/container.go b/api/webprojects/container.go index 2849b48..564ae12 100644 --- a/api/webprojects/container.go +++ b/api/webprojects/container.go @@ -82,7 +82,8 @@ func contentNotFound(c *gin.Context) { return } if strings.Contains(c.GetHeader("Accept"), "text/html") { - c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`404

404

页面不存在或不可见。

系统主页`)) + c.Header("Referrer-Policy", "no-referrer") + c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(`404

404

页面不存在或不可见。

系统主页`)) } else { c.Status(http.StatusNotFound) } diff --git a/front_vue/src/views/AdminConfig.vue b/front_vue/src/views/AdminConfig.vue index 30da7b6..32eb50e 100644 --- a/front_vue/src/views/AdminConfig.vue +++ b/front_vue/src/views/AdminConfig.vue @@ -106,9 +106,11 @@ export default { this.validating = true; this.historyError = '' try { const candidate = {...record.values} - if (this.namespace === 'account_policy' && !Object.prototype.hasOwnProperty.call(candidate, 'max_active_sessions')) { - const field = this.schema.fields.find(field => field.key === 'max_active_sessions') - if (field) candidate.max_active_sessions = field.default_value + if (this.namespace === 'account_policy') { + for (const key of ['max_active_sessions', 'min_share_password_length', 'share_code_length']) { + const field = this.schema.fields.find(field => field.key === key) + if (field && !Object.prototype.hasOwnProperty.call(candidate, key)) candidate[key] = field.default_value + } } const values = configValues(this.schema, candidate) this.validation = await accountsApi.validateConfig(this.namespace, values) diff --git a/front_vue/test/accounts-pages.test.cjs b/front_vue/test/accounts-pages.test.cjs index 2800b0f..fe0726b 100644 --- a/front_vue/test/accounts-pages.test.cjs +++ b/front_vue/test/accounts-pages.test.cjs @@ -74,13 +74,13 @@ test('a config publish conflict retains the draft and revision for explicit revi assert.equal(view.pendingRequest, null) }) -test('legacy account policy rollback fills the additive session limit without modifying history', async () => { +test('legacy account policy rollback fills additive session and password limits without modifying history', async () => { const legacy = {session_ttl_seconds: 604800, temporary_password_ttl_days: 7, min_password_length: 15, bcrypt_cost: 12} - const fields = Object.entries({...legacy, max_active_sessions: 5}).map(([key, default_value]) => ({key, label: key, type: 'integer', default_value, minimum: 1, maximum: key === 'max_active_sessions' ? 100 : 2592000})) + const fields = Object.entries({...legacy, max_active_sessions: 5, min_share_password_length: 8, share_code_length: 6}).map(([key, default_value]) => ({key, label: key, type: 'integer', default_value, minimum: 1, maximum: key === 'max_active_sessions' ? 100 : 2592000})) const requests = [] const {view} = component('AdminConfig', { validateConfig: async (...args) => {requests.push(['validate', ...args]); return {revision: 7, values: args[1]}}, - rollbackConfig: async (...args) => {requests.push(['rollback', ...args]); return {namespace: 'account_policy', revision: 8, values: {...legacy, max_active_sessions: 5}}}, + rollbackConfig: async (...args) => {requests.push(['rollback', ...args]); return {namespace: 'account_policy', revision: 8, values: {...legacy, max_active_sessions: 5, min_share_password_length: 8, share_code_length: 6}}}, configStatus: async () => ({apply_state: 'applied'}), }) view.namespace = 'account_policy'; view.schemas = [{namespace: 'account_policy', fields}] @@ -90,6 +90,9 @@ test('legacy account policy rollback fills the additive session limit without mo await view.prepareRollback(record) assert.equal(view.historyError, '') assert.equal(requests[0][2].max_active_sessions, 5) + assert.equal(requests[0][2].min_share_password_length, 8) + assert.equal(requests[0][2].share_code_length, 6) + assert.equal(Object.hasOwn(record.values, 'share_code_length'), false) assert.equal(Object.hasOwn(record.values, 'max_active_sessions'), false) assert.equal(view.draft.max_active_sessions, 3) assert.equal(view.confirmVisible, true) diff --git a/front_vue/test/password-query-browser.cjs b/front_vue/test/password-query-browser.cjs index 5111233..35d6715 100644 --- a/front_vue/test/password-query-browser.cjs +++ b/front_vue/test/password-query-browser.cjs @@ -37,3 +37,15 @@ for (const code of ['1234', 'wrong', '']) { await page.close() }) } + +test('unavailable page keeps 404 while removing code before its home link can forward a referrer', async () => { + const source = fs.readFileSync(path.join(__dirname, '../../api/webprojects/container.go'), 'utf8') + const notFound = source.match(/`(404[\s\S]*?)`/)[1] + const page = await browser.newPage() + await page.route('https://home.example.com/**', route => route.fulfill({status: 404, contentType: 'text/html', headers: {'Referrer-Policy': 'no-referrer'}, body: notFound})) + const response = await page.goto('https://home.example.com/p/private/?code=1234&view=wide#section') + assert.equal(response.status(), 404) + assert.equal(page.url(), 'https://home.example.com/p/private/?view=wide#section') + await page.getByRole('heading', {name: '404'}).waitFor() + await page.close() +})