diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f24db..1023b56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: - uses: android-actions/setup-android@v3 with: packages: platform-tools platforms;android-35 build-tools;35.0.1 - - run: ./gradlew --no-daemon :app:assembleDebug + - run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug working-directory: android - uses: actions/upload-artifact@v4 with: diff --git a/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt b/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt index 0bf6eec..9e2ad4b 100644 --- a/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt +++ b/android/app/src/main/java/com/htmake/readerpro/web/WebHost.kt @@ -1,5 +1,8 @@ package com.htmake.readerpro.web +import java.net.URI +import java.util.Locale + import android.annotation.SuppressLint import android.content.Context import android.content.Intent @@ -163,7 +166,7 @@ class WebHost( override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val url = request.url.toString() - if (isInternalUrl(url)) return false + if (isInternalUrl(url, request.isForMainFrame)) return false openExternally(url) return true } @@ -217,13 +220,13 @@ class WebHost( * images and stylesheets out of the in-memory resource map; letting it be treated * as external would break every illustrated book. */ -internal fun isInternalUrl(url: String): Boolean { - val internalPrefixes = listOf( - WebHost.ASSET_ORIGIN, - "reader-res:", - "blob:", - "data:", - "about:", - ) - return internalPrefixes.any { url.startsWith(it) } +internal fun isInternalUrl(url: String, isMainFrame: Boolean = true): Boolean { + val uri = runCatching { URI(url) }.getOrNull() ?: return false + return when (uri.scheme?.lowercase(Locale.ROOT)) { + "https" -> uri.host.equals(WebHost.ASSET_HOST, ignoreCase = true) && + uri.rawUserInfo == null && (uri.port == -1 || uri.port == 443) && + uri.rawPath.orEmpty().startsWith("/assets/") + "reader-res", "blob", "data", "about" -> !isMainFrame + else -> false + } } diff --git a/android/app/src/test/java/com/htmake/readerpro/web/WebHostUrlTest.kt b/android/app/src/test/java/com/htmake/readerpro/web/WebHostUrlTest.kt new file mode 100644 index 0000000..94f2682 --- /dev/null +++ b/android/app/src/test/java/com/htmake/readerpro/web/WebHostUrlTest.kt @@ -0,0 +1,33 @@ +package com.htmake.readerpro.web + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebHostUrlTest { + @Test fun onlyExactAssetOriginsStayInsideTheMainFrame() { + for (url in listOf( + "https://appassets.androidplatform.net/assets/index.html", + "https://APPASSETS.ANDROIDPLATFORM.NET:443/assets/index.html#/home", + )) assertTrue(url, isInternalUrl(url)) + + for (url in listOf( + "https://appassets.androidplatform.net.evil.test/assets/index.html", + "https://appassets.androidplatform.net@evil.test/assets/index.html", + "https://user@appassets.androidplatform.net/assets/index.html", + "https://appassets.androidplatform.net:444/assets/index.html", + "https://appassets.androidplatform.net/other/index.html", + "http://appassets.androidplatform.net/assets/index.html", + "https://appassets.androidplatform.net\\@evil.test/assets/index.html", + "javascript:alert(1)", + "not a valid URI", + )) assertFalse(url, isInternalUrl(url)) + } + + @Test fun documentSchemesAreRestrictedToSubframes() { + for (url in listOf("reader-res:cover.png", "blob:https://example.com/id", "data:text/html,hello", "about:blank")) { + assertFalse(url, isInternalUrl(url)) + assertTrue(url, isInternalUrl(url, isMainFrame = false)) + } + } +} diff --git a/docs/api.md b/docs/api.md index 09978a6..a3fbd1c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,6 +69,8 @@ ## 认证 +认证请求超出频率或密码派生并发上限时返回 `429 AUTH_RATE_LIMITED` / `429 AUTH_BUSY`,并通过 `Retry-After` 指示最少等待秒数。客户端不应立即重试。 + ### `POST /auth/register` — 公开 ```json @@ -87,6 +89,7 @@ { "user": { "id": "...", "username": "owner", "displayName": "图书馆长", "role": "admin", "createdAt": 1789537801289 }, "session": { "user": {...}, "accessToken": "...", "accessTokenExpiresAt": 1789624201289, + "resourceToken": "...", "resourceTokenExpiresAt": 1789538701289, "refreshToken": "...", "refreshTokenExpiresAt": 1821073801289 } } ``` @@ -103,6 +106,8 @@ **刷新令牌会轮转**:传入的那个立即作废。客户端必须持久化新的那个。 +登录、注册和刷新同时签发 `resourceToken`,最长有效期为 15 分钟,不超过访问令牌的有效期。它只允许通过 `resource_token` 查询参数访问本账号可读的书籍资源和 HTTP 朗读,不能用作普通 API 的 Bearer 凭据。客户端应依据 `resourceTokenExpiresAt` 提前刷新;旧服务端缺少这些字段时可回退到旧访问令牌。密码变更、账号停用和认证版本失效同样会撤销资源票据。 + ### `POST /auth/logout` `{ "refreshToken": "..." }` → `{ "ok": true }`。幂等,重复调用不报错。 @@ -307,16 +312,16 @@ pdf / 单图没有独立目录,回落到 items。来源章节出版物(`chap 所以服务端会说 `Accept-Ranges: none` 并回 200,让客户端能做计划,而不是付出一次它没 预期到的下载。 -**子资源可以带 `?access_token=`。** 章节里的 ``、`` 是浏览器 +**子资源应携带 `?resource_token=`。** 为兼容旧客户端仍接受 `?access_token=`。章节里的 ``、`` 是浏览器 自己发起的,没法带 `Authorization` 头。这类请求允许把令牌放进 query, -但**仅限** `GET` + 下列端点: +但**仅限** `GET` / `HEAD` + 下列端点: - `/api/v1/books/:id/assets` - `/api/v1/books/:id/cover` - `/api/v1/books/:id/content` +- `/api/v1/tts`(HTTP 朗读) -其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌 —— URL 会进日志、 -进历史、进 `Referer`,能改状态的令牌不该走那条路。 +其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌。短期资源票据不能访问一般 API,也不能修改状态。服务端请求日志不记录查询参数,并设置 `Referrer-Policy: no-referrer`;反向代理日志仍应另外配置凭据脱敏。 **epub 章节返回的是重写过的 HTML。** 章节内的相对资源引用(`images/pic.png`)已经被 服务端改写成指向本端点的绝对地址。客户端直接把 HTML 交给 WebView 即可,不需要自己 @@ -711,7 +716,7 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** 一个配错的 HTTP 朗读合成接口(指到一个网页)会以 200 返回 HTML,塞进 `