From a41e8d9c22cf0a385e4788e1f15726aa9822d92d Mon Sep 17 00:00:00 2001 From: axuitomo <3157269069@qq.com> Date: Sun, 20 Sep 2026 19:52:35 +0800 Subject: [PATCH 1/2] fix(security): clear CodeQL code scanning alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go/zipslip:zip/tar 解压改用 filepath.IsLocal 正向守卫(CodeQL 只识别该形式),删掉 safeArchiveTargetPath,补两个穿越用例。 - java/android/implicit-pendingintents:通知用的 PendingIntent 改为 Intent() + setClass 显式组件,并删除两个从未被读取的自定义 action。 - node_modules 里的 Capacitor 源码随 Gradle 子项目进入 Kotlin 数据库:新增 .github/codeql/codeql-config.yml 的 paths-ignore 并在 init 步骤引用。 --- .github/codeql/codeql-config.yml | 9 ++ .github/workflows/codeql.yml | 2 + internal/app/update.go | 150 +++++++++--------- internal/app/update_test.go | 73 +++++++++ .../AndroidKeepAliveForegroundService.kt | 10 +- .../cfstgui/ProbeForegroundService.kt | 10 +- 6 files changed, 172 insertions(+), 82 deletions(-) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..bcb0e5f2 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,9 @@ +# CodeQL 扫描配置。 +# +# node_modules 里的 Capacitor Android 源码会被 Gradle 作为子项目一起编译,因此进入 Kotlin/Java +# 数据库并被扫描,报出的是第三方代码自身的问题(java/xss、java/error-message-exposure)。 +# 依赖源码的问题交给依赖升级和 Dependabot 跟踪,不在这里重复报出。 +name: "CFST-GUI CodeQL config" + +paths-ignore: + - "node_modules/**" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 64965e23..8e551b9c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -110,6 +110,8 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + # 排除 node_modules 等第三方源码,见配置文件内的说明。 + config-file: ./.github/codeql/codeql-config.yml # Android's Gradle project lives under mobile/android and needs the generated AAR. - name: Build Android project for CodeQL diff --git a/internal/app/update.go b/internal/app/update.go index 3fc5cdce..446bc946 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -585,34 +585,40 @@ func untarRegularFiles(reader io.Reader, targetDir string) ([]string, error) { if header.Size > maxArchiveEntryBytes { return nil, fmt.Errorf("更新包解压超限:%s 超过单文件上限 %d MiB", header.Name, maxArchiveEntryBytes>>20) } - targetPath, ok := safeArchiveTargetPath(targetDir, header.Name) - if !ok { + name := filepath.Clean(strings.ReplaceAll(header.Name, "\\", "/")) + if name == "." { continue } - if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return nil, err - } - file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, header.FileInfo().Mode()) - if err != nil { - return nil, err - } - copied, copyErr := io.Copy(file, io.LimitReader(tarReader, maxArchiveEntryBytes+1)) - closeErr := file.Close() - if copyErr != nil { - return nil, copyErr - } - if closeErr != nil { - return nil, closeErr - } - if copied > maxArchiveEntryBytes { - return nil, fmt.Errorf("更新包解压超限:%s 超过单文件上限 %d MiB", header.Name, maxArchiveEntryBytes>>20) - } - totalBytes += copied - if totalBytes > maxArchiveTotalBytes { - return nil, fmt.Errorf("更新包解压超过总大小上限 %d MiB", maxArchiveTotalBytes>>20) - } - if header.FileInfo().Mode()&0o111 != 0 || strings.Contains(strings.ToLower(filepath.Base(targetPath)), "cfst-gui") { - entries = append(entries, targetPath) + // 解压目标必须落在 targetDir 内(tar slip)。这里保持 filepath.IsLocal 正向判断: + // 它是 Go 官方的逃逸检查,也是安全扫描(CodeQL go/zipslip)识别为净化的形式, + // 改成 `if !filepath.IsLocal(name) { continue }` 会让告警重新出现。 + if filepath.IsLocal(name) { + targetPath := filepath.Join(targetDir, name) + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return nil, err + } + file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, header.FileInfo().Mode()) + if err != nil { + return nil, err + } + copied, copyErr := io.Copy(file, io.LimitReader(tarReader, maxArchiveEntryBytes+1)) + closeErr := file.Close() + if copyErr != nil { + return nil, copyErr + } + if closeErr != nil { + return nil, closeErr + } + if copied > maxArchiveEntryBytes { + return nil, fmt.Errorf("更新包解压超限:%s 超过单文件上限 %d MiB", header.Name, maxArchiveEntryBytes>>20) + } + totalBytes += copied + if totalBytes > maxArchiveTotalBytes { + return nil, fmt.Errorf("更新包解压超过总大小上限 %d MiB", maxArchiveTotalBytes>>20) + } + if header.FileInfo().Mode()&0o111 != 0 || strings.Contains(strings.ToLower(filepath.Base(targetPath)), "cfst-gui") { + entries = append(entries, targetPath) + } } } return entries, nil @@ -710,46 +716,52 @@ func unzip(sourcePath, targetDir string) error { if file.UncompressedSize64 > uint64(maxArchiveEntryBytes) { return fmt.Errorf("更新包解压超限:%s 超过单文件上限 %d MiB", file.Name, maxArchiveEntryBytes>>20) } - targetPath, ok := safeArchiveTargetPath(targetDir, file.Name) - if !ok { + name := filepath.Clean(strings.ReplaceAll(file.Name, "\\", "/")) + if name == "." { continue } - if file.FileInfo().IsDir() { - if err := os.MkdirAll(targetPath, file.FileInfo().Mode()); err != nil { + // 解压目标必须落在 targetDir 内(zip slip)。这里保持 filepath.IsLocal 正向判断: + // 它是 Go 官方的逃逸检查,也是安全扫描(CodeQL go/zipslip)识别为净化的形式, + // 改成 `if !filepath.IsLocal(name) { continue }` 会让告警重新出现。 + if filepath.IsLocal(name) { + targetPath := filepath.Join(targetDir, name) + if file.FileInfo().IsDir() { + if err := os.MkdirAll(targetPath, file.FileInfo().Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return err } - continue - } - if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return err - } - input, err := file.Open() - if err != nil { - return err - } - output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode()) - if err != nil { - _ = input.Close() - return err - } - copied, copyErr := io.Copy(output, io.LimitReader(input, maxArchiveEntryBytes+1)) - closeInputErr := input.Close() - closeOutputErr := output.Close() - if copyErr != nil { - return copyErr - } - if closeInputErr != nil { - return closeInputErr - } - if closeOutputErr != nil { - return closeOutputErr - } - if copied > maxArchiveEntryBytes { - return fmt.Errorf("更新包解压超限:%s 超过单文件上限 %d MiB", file.Name, maxArchiveEntryBytes>>20) - } - totalBytes += copied - if totalBytes > maxArchiveTotalBytes { - return fmt.Errorf("更新包解压超过总大小上限 %d MiB", maxArchiveTotalBytes>>20) + input, err := file.Open() + if err != nil { + return err + } + output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.FileInfo().Mode()) + if err != nil { + _ = input.Close() + return err + } + copied, copyErr := io.Copy(output, io.LimitReader(input, maxArchiveEntryBytes+1)) + closeInputErr := input.Close() + closeOutputErr := output.Close() + if copyErr != nil { + return copyErr + } + if closeInputErr != nil { + return closeInputErr + } + if closeOutputErr != nil { + return closeOutputErr + } + if copied > maxArchiveEntryBytes { + return fmt.Errorf("更新包解压超限:%s 超过单文件上限 %d MiB", file.Name, maxArchiveEntryBytes>>20) + } + totalBytes += copied + if totalBytes > maxArchiveTotalBytes { + return fmt.Errorf("更新包解压超过总大小上限 %d MiB", maxArchiveTotalBytes>>20) + } } } return nil @@ -776,20 +788,6 @@ func findFirstAppBundle(root string) (string, error) { return found, nil } -func safeArchiveTargetPath(baseDir, name string) (string, bool) { - cleanName := filepath.Clean(strings.ReplaceAll(name, "\\", "/")) - cleanName = strings.TrimPrefix(cleanName, string(filepath.Separator)) - if cleanName == "." || strings.HasPrefix(cleanName, "..") { - return "", false - } - targetPath := filepath.Join(baseDir, cleanName) - rel, err := filepath.Rel(baseDir, targetPath) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", false - } - return targetPath, true -} - func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/internal/app/update_test.go b/internal/app/update_test.go index aa28d48b..a2667b9f 100644 --- a/internal/app/update_test.go +++ b/internal/app/update_test.go @@ -678,6 +678,79 @@ func TestUnzipRejectsOversizedEntry(t *testing.T) { } } +func TestUnzipSkipsEscapingEntries(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "traversal.zip") + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for _, name := range []string{"../escaped.txt", "safe/inside.txt"} { + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(name)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(zipPath, buffer.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + outDir := filepath.Join(dir, "out") + if err := unzip(zipPath, outDir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "escaped.txt")); err == nil { + t.Fatal("zip entry escaped the target directory") + } + body, err := os.ReadFile(filepath.Join(outDir, "safe", "inside.txt")) + if err != nil { + t.Fatal(err) + } + if string(body) != "safe/inside.txt" { + t.Fatalf("unexpected entry content: %s", body) + } +} + +func TestUntarSkipsEscapingEntries(t *testing.T) { + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + for _, name := range []string{"../escaped.txt", "safe/inside.txt"} { + body := []byte(name) + if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body))}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write(body); err != nil { + t.Fatal(err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + gzipReader, err := gzip.NewReader(bytes.NewReader(buffer.Bytes())) + if err != nil { + t.Fatal(err) + } + defer gzipReader.Close() + outDir := filepath.Join(t.TempDir(), "out") + entries, err := untarRegularFiles(gzipReader, outDir) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(outDir), "escaped.txt")); err == nil { + t.Fatal("tar entry escaped the target directory") + } + if len(entries) != 1 || filepath.Base(entries[0]) != "inside.txt" { + t.Fatalf("unexpected extracted entries: %v", entries) + } +} + type roundTripFunc func(req *http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/AndroidKeepAliveForegroundService.kt b/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/AndroidKeepAliveForegroundService.kt index b212dc9e..9e702ba8 100644 --- a/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/AndroidKeepAliveForegroundService.kt +++ b/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/AndroidKeepAliveForegroundService.kt @@ -88,9 +88,13 @@ class AndroidKeepAliveForegroundService : Service() { } private fun openAppIntent(): PendingIntent { - val intent = Intent(this, MainActivity::class.java).apply { - action = "io.github.axuitomo.cfstgui.action.OPEN_FROM_KEEP_ALIVE" - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + // 显式 setClass 指定目标组件,避免隐式 Intent 被其他应用劫持 + // (CodeQL java/android/implicit-pendingintents 只识别 setClass 这类显式写法)。 + val intent = Intent() + intent.setClass(this, MainActivity::class.java) + intent.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } return PendingIntent.getActivity( this, diff --git a/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/ProbeForegroundService.kt b/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/ProbeForegroundService.kt index 8be9fe21..a1992151 100644 --- a/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/ProbeForegroundService.kt +++ b/mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/ProbeForegroundService.kt @@ -264,9 +264,13 @@ class ProbeForegroundService : Service() { } private fun openAppIntent(): PendingIntent { - val intent = Intent(this, MainActivity::class.java).apply { - action = "io.github.axuitomo.cfstgui.action.OPEN_FROM_NOTIFICATION" - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + // 显式 setClass 指定目标组件,避免隐式 Intent 被其他应用劫持 + // (CodeQL java/android/implicit-pendingintents 只识别 setClass 这类显式写法)。 + val intent = Intent() + intent.setClass(this, MainActivity::class.java) + intent.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } return PendingIntent.getActivity( this, From b3b367445109dd850c8aff38f4d79fec615fdd73 Mon Sep 17 00:00:00 2001 From: axuitomo <3157269069@qq.com> Date: Sun, 20 Sep 2026 19:52:43 +0800 Subject: [PATCH 2/2] fix(android): prevent white flashes and follow the app theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 渲染进程被系统回收后由 MainActivity 重建 WebView(进程、Go runtime 与运行中的探测任务不中断);启动窗口、窗口背景与 WebView 底色取同一颜色,frontend/index.html 的内联首帧启动画面兜住 Vue 挂载前的空档。 - 主题跟随前端配置:frontend/src/lib/surfaceTheme.ts 把解析结果写入 localStorage 并调用 SetSurfaceTheme,AndroidSurfaceTheme 缓存到 SharedPreferences,MainActivity 在 super.onCreate 之前按缓存值 setTheme,切换主题时刷新窗口与 WebView 底色。 - Android 12+ 启动窗口图标改用 @mipmap/ic_launcher;values-night 覆盖系统深色模式下的启动窗口底色。 --- docs/mobile/android-mobile.md | 4 ++ frontend/index.html | 46 ++++++++++++ frontend/src/App.vue | 2 + frontend/src/lib/bridge.ts | 12 ++++ frontend/src/lib/surfaceTheme.test.ts | 58 +++++++++++++++ frontend/src/lib/surfaceTheme.ts | 25 +++++++ frontend/src/main.ts | 11 +++ mobile/android/app/detekt-baseline-debug.xml | 1 + .../axuitomo/cfstgui/AndroidSurfaceTheme.kt | 25 +++++++ .../io/github/axuitomo/cfstgui/CfstPlugin.kt | 14 ++++ .../github/axuitomo/cfstgui/MainActivity.kt | 71 ++++++++++++++++++- .../app/src/main/res/values-night/styles.xml | 13 ++++ .../app/src/main/res/values/colors.xml | 13 ++++ .../app/src/main/res/values/styles.xml | 11 +++ .../cfstgui/AndroidSurfaceThemeTest.kt | 37 ++++++++++ 15 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/surfaceTheme.test.ts create mode 100644 frontend/src/lib/surfaceTheme.ts create mode 100644 mobile/android/app/src/main/java/io/github/axuitomo/cfstgui/AndroidSurfaceTheme.kt create mode 100644 mobile/android/app/src/main/res/values-night/styles.xml create mode 100644 mobile/android/app/src/main/res/values/colors.xml create mode 100644 mobile/android/app/src/test/java/io/github/axuitomo/cfstgui/AndroidSurfaceThemeTest.kt diff --git a/docs/mobile/android-mobile.md b/docs/mobile/android-mobile.md index 184f6239..23239768 100644 --- a/docs/mobile/android-mobile.md +++ b/docs/mobile/android-mobile.md @@ -162,6 +162,10 @@ Activity 使用 `adjustResize`;前端只通过 `visualViewport` 计算键盘 i Android 原生层会关闭 theme force dark、WebView `FORCE_DARK_OFF` 和 Android 13+ algorithmic darkening,避免 WebView 或系统深色策略把按钮背景自动变淡、把按钮文字改成低对比颜色。 +App 长时间留在后台后,系统在内存压力下可能只回收 WebView 渲染进程而保留 app 进程(常驻前台服务会先保住 app 进程)。`MainActivity` 处理 `WebViewClient.onRenderProcessGone` 并返回 true,因为默认行为会在渲染进程被系统杀掉时终止整个 app 进程,正在运行的探测任务会被任务快照标记成 `recovery_required`。原生侧只记录状态,等 app 回到前台时再 `recreate()` Activity,由新的 Capacitor bridge 和 WebView 重新加载前端;Go runtime、运行中的任务和事件序号保持不中断。 + +重建与冷启动期间的白色由同一底色的三层覆盖,并跟随前端主题。启动窗口底色来自启动主题的 `windowSplashScreenBackground`,它同时是 `Theme.SplashScreen` 里 `android:windowBackground` 的取值来源(`@drawable/compat_splash_screen_no_icon_background`),因此 Activity 窗口自建立起就没有白底;`recreate()` 在 resumed 状态触发,系统按 `preserveWindow` 保留窗口,间隙显示的是被保留窗口的背景而不是新启动窗口;`MainActivity.refreshSurfaceColors()` 在 `onCreate` 和主题切换时把窗口背景与 WebView 底色设为同一颜色,覆盖 index.html 首帧之前的空白。颜色取 `res/values/colors.xml` 的 `app_background`(`#F5F7FC`)与 `app_background_dark`(`#09090B`),与前端 `styles.css` 的 `--app-bg` 同步。主题的真实来源是前端配置:`frontend/src/App.vue` 每次解析出主题后调用 `frontend/src/lib/surfaceTheme.ts`,后者把模式写入 `localStorage` 供启动画面读取,并调用原生方法 `SetSurfaceTheme`;原生侧 `AndroidSurfaceTheme` 把模式缓存到 `SharedPreferences`,`MainActivity` 在 `super.onCreate` 之前用缓存值 `setTheme` 选出 `AppTheme.NoActionBarLaunch.Dark`,并在切换时立即刷新窗口与 WebView 底色,不需要重建 Activity。冷启动第一帧的系统启动窗口早于 app 代码运行、读不到前端缓存,只能跟随系统深色模式(`res/values-night/styles.xml` 提供深色版本):`auto_system_time` 档位下与运行期一致,前端强制浅色而系统为深色时首帧仍可能偏深。Vue 包加载与启动步骤这段时间由 `frontend/index.html` 内联的首帧启动画面覆盖(App 底色 + `/favicon.png`,`localStorage` 里记录的深色主题会让它取 `#09090b`,挂载后 `frontend/src/main.ts` 淡出移除,index.html 自带 10s 兜底),桌面端 / WebUI / Android 共用。启动主题同时把 `windowSplashScreenAnimatedIcon` 设为 `@mipmap/ic_launcher`,Android 12 及以上不再显示系统通用图标;未接入 `installSplashScreen()`,Android 11 及以下启动窗口仍只显示背景色。 + Android 原生 select 在部分 WebView 中会显示为系统白色大面板;前端会在 Android app 环境拦截 `select` 的 pointer/touch/click 事件,改用应用内底部 picker。该 picker 支持点外关闭、滚动区域选择、Esc 关闭、禁用项和基础 `role=listbox/option` ARIA 状态。 ## Notes diff --git a/frontend/index.html b/frontend/index.html index 41110e33..241805c2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,10 +5,56 @@