From e3ede5f5f2cd8aa6b1b7bf8584cd1f2980232304 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 29 Oct 2026 22:21:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=9B=BE=E7=89=87=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E9=9D=A2=E6=9D=BF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/file-access/README.md | 22 +++- docs/file-access/config.example.yml | 5 + docs/file-access/verification.md | 12 ++ .../core/file/FileAccessProperties.java | 15 +++ .../admin/service/file/FileAccessGuard.java | 32 ++++- .../service/file/FileAccessMobileTest.java | 112 ++++++++++++++++++ .../admin/service/file/FileAccessTest.java | 2 +- scripts/generate-file-access-config.py | 1 + .../tests/test_generate_file_access_config.py | 3 + 9 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java diff --git a/docs/file-access/README.md b/docs/file-access/README.md index f5de41374..b53481ba3 100644 --- a/docs/file-access/README.md +++ b/docs/file-access/README.md @@ -31,6 +31,10 @@ python3 scripts/generate-file-access-config.py \ 前端的配置项为 `VITE_FILE_ACCESS_APP_ID`、`VITE_FILE_ACCESS_KEY_ID`、`VITE_FILE_ACCESS_KEY`、`VITE_FILE_ACCESS_ALLOWED_ORIGINS`。Flutter 去掉 `VITE_` 前缀。Java 的 `clients` 包含 `admin/h5/mini/flutter/service/package`;`service` 和 `package` 密钥只放服务端。 +**自动构建不会取得被 Git 忽略的 `.env.local`。** 后台的 `build`、`build:prod`、`build:docker` 统一使用 `--mode prod`,自动工具执行 `npm run build` / `pnpm build` 时读取 `.env.prod`。自动构建应从已提交的 `.env.prod` 或流水线环境变量取得同一组 admin 配置,继续使用既有密钥;不要为流水线重新生成密钥。缺少必要配置时构建会失败,发布流程应停止,不能继续部署上一次留下的 `dist`。 + +H5 使用 uni-app 的 `production` 模式,自动构建配置为该项目的 `.env.production`,其中 appId 为 `h5`,复用既有 H5 密钥。`build`、`build:h5` 显式使用 `uni build -p h5 --mode production`;Dockerfile 的 `pnpm build:h5` 同样读取该文件,默认产物目录为 `dist/build/h5`。自动构建所需的新配置文件须与源码一起提交;本地 `.env.local` 仍可用于开发,不作为 CI 输入。 + `public-base-url` 是外部可访问的网关基础地址,可以带 `/api`,不含 `/admin/sys-file/oss/file`。现有的图片/API 基础地址继续由各端原有配置决定;`allowed-origins` / `ALLOWED_ORIGINS` 只列确实属于本系统的历史域名。上线前必须逐环境核对域名、基础路径、appId、kid 和密钥。相对 `/api/admin/sys-file/oss/file` 按当前文件基础地址解析,绝对历史地址保留其原有域名和部署路径。 **Java 不会自动读取仓库里的 `.file-access`。** 本地运行可以设置: @@ -39,7 +43,7 @@ python3 scripts/generate-file-access-config.py \ export SPRING_CONFIG_ADDITIONAL_LOCATION=file:/absolute/path/pj_service_cloud/.file-access/config.yml ``` -通过现有 Nacos 配置体系加载这套 `file-access` 配置,或把外部配置挂载到各服务并设置上述变量。实际部署路径与公网基础地址应对应目标机器。首次生成默认为 `observe`,已有配置保留当前模式;显式 `--mode` 可更新本地生成配置的模式。先检查配置源优先级;`config.example.yml` 中的环境变量占位符不能代替实际密钥。严格模式缺少任一客户端的有效活动密钥或公网基础地址会启动失败;运行中错误配置会返回 500,不会降级匿名访问。即使在观察模式,版本签发和服务间读取也需要配置 `package/service` 密钥。 +通过现有 Nacos 配置体系加载这套 `file-access` 配置,或把外部配置挂载到各服务并设置上述变量。实际部署路径与公网基础地址应对应目标机器。首次生成默认为 `observe`,已有配置保留当前模式;显式 `--mode` 可更新本地生成配置的模式。先检查配置源优先级;`config.example.yml` 中的环境变量占位符不能代替实际密钥。严格模式要求 `admin/mini/flutter/service/package` 的有效活动密钥及公网基础地址。`h5` 条目可整体省略;一旦配置该条目,密钥也必须完整。未配置 H5 不会自动放行 H5 请求,只有下面明确配置的图片来源可免签。运行中错误配置返回 500,不会降级匿名访问。即使在观察模式,版本签发和服务间读取也需要配置 `package/service` 密钥。 ## 协议 @@ -64,6 +68,22 @@ Base64URL无填充编码的UTF-8文件名 后台会截获本系统文件 `` 链接,通过已有 Blob 下载工具处理;H5/小程序富文本文件链接改走图片预览或下载后打开本地文件。复制出来的接口地址不再承诺可直接在浏览器打开。Flutter 原生下载、Feign 等没有浏览器 Fetch Metadata 的请求仍执行签名/旧 Dart 规则;不支持这些请求头的旧浏览器同样不能靠本规则识别。这项规则用于限制普通浏览器导航,不能防止脚本携带有效签名并模拟请求,也不能阻止已经获准显示的图片被保存。 +## 旧 mobile H5 的图片来源兼容 + +旧 H5 无需重新构建或更新。在更新后的后端配置中增加: + +```yaml +file-access: + public-image-referer-prefixes: + - https://www.pjzhhq.cn/mobile/ +``` + +只对 GET/HEAD 图片读取生效:请求带有单个 `Referer`,其协议、主机、端口与前缀匹配,且路径位于 `/mobile/` 下时,JPG/JPEG/PNG/GIF/WebP/BMP/SVG/ICO 可以不带 appId 或签名读取。页面 URL 的 `#/` 不会随 HTTP Referer 发送。来源命中的图片请求整体免签,因此旧页面携带的过期签名也不阻止加载。重复关键参数、非法文件名和浏览器页面导航仍优先拒绝;PDF、APK、日志等文件及上传接口不享受此例外。 + +这是独立的来源白名单,保留或省略 `clients.h5` 都可使用;不能仅根据 `appId=h5` 放行。新版本允许省略整个 H5 客户端配置,但空密钥、错误密钥配置仍报错,其他五类客户端的必要配置保持严格检查。若删除共享配置中的 H5 条目,所有加载旧版公共配置类的服务也需要同步更新;保留现有 H5 配置同样可以启用来源兼容。 + +配置默认空列表;显式清空列表即可恢复图片签名要求。必须部署新 UPMS 和对应的公共依赖后再启用配置,不能用旧后端只删除 H5 密钥。Referer 必须由浏览器和代理保留;没有 Referer 的匿名请求仍返回 403。Referer 可被脚本模拟,这是一层兼容性的来源检查。成功响应私有缓存最多 5 分钟,`Vary` 额外区分 Referer,代理不得忽略该差异复用放行响应。 + ## 上传身份与文件类型 现有登录鉴权及 `@Inner` 内部调用限制保留。每次 POST multipart 上传只允许一个名为 `file` 的文件部分,通过下列请求头传递身份,不把上传凭据放进 URL 或业务字段: diff --git a/docs/file-access/config.example.yml b/docs/file-access/config.example.yml index 12e7d7509..9fdeded4e 100644 --- a/docs/file-access/config.example.yml +++ b/docs/file-access/config.example.yml @@ -8,6 +8,10 @@ file-access: public-base-url: https://files.example.com/api allowed-origins: - https://legacy-files.example.com + # Optional: old H5 needs no update; only image reads from this Referer prefix bypass signatures. + # Remove this list or set [] to keep all browser sources subject to signing. + public-image-referer-prefixes: + - https://www.pjzhhq.cn/mobile/ legacy-dart: enabled: true extensions: [jpg, jpeg, png, gif, webp, bmp, svg, ico] @@ -21,6 +25,7 @@ file-access: active-kid: v1 keys: {v1: '${FILE_ACCESS_ADMIN_V1:}'} h5: + # Optional. Remove the whole h5 node if no H5 signing key is configured. active-kid: v1 keys: {v1: '${FILE_ACCESS_H5_V1:}'} mini: diff --git a/docs/file-access/verification.md b/docs/file-access/verification.md index 6de313387..960949491 100644 --- a/docs/file-access/verification.md +++ b/docs/file-access/verification.md @@ -128,3 +128,15 @@ Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。 - 实际图只通过本机可选测试参数读取,没有作为仓库测试素材提交。验证日志在 `/tmp/pj-upload-image-format-work/`。 后端识别逻辑需要发布或重启更新后的 UPMS 才会生效;本次未更改运行环境、密钥或 Nacos。 + +## 旧 mobile H5 图片免签来源补充 + +2026-10-29 按用户要求,仅修改后端,为不能更新的 `https://www.pjzhhq.cn/mobile/` H5 提供图片兼容。新增 `file-access.public-image-referer-prefixes`,默认空列表。请求 Referer 的协议、主机、有效端口和路径前缀全部匹配时,GET/HEAD 常见图片可免签,包括没有 appId 的旧请求;匹配来源携带的旧签名不再影响图片读取。Hash 路由不参与匹配。 + +- 配置整体省略 `clients.h5` 可通过新版本校验;配置了空 H5 密钥仍拒绝启动,其他五类必要客户端维持严格要求。仅移除 H5 密钥不会自动放行任意 H5 请求。 +- 重复参数、非法文件名、浏览器页面导航仍先拒绝;其他来源、PDF/APK/日志/压缩包和上传入口不走该例外。 +- 已覆盖域名后缀伪造、userinfo、错误协议/端口、相似路径、路径穿越、重复 Referer、开关关闭、GET/HEAD、私有缓存和 `Vary: Referer`。 +- Java 跨模块 78 项回归通过,0 失败/跳过;配置生成器 4 项通过,确认默认不开启、已有前缀不被重写。 +- 实际检查线上 `/mobile/` 返回 200,未发现禁用 Referer 的响应头或 HTML meta;这不代替后端部署后的真机验收。 + +精确前缀已加入本机被忽略的生成配置,其余配置和密钥保留。生产 Nacos 和线上服务未修改;需要部署新 UPMS/公共依赖并加载来源白名单,H5 无需重新发布。Referer 可被脚本模拟,此功能为应用级来源兼容。 diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java index 688e46700..e7da1e800 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/file/FileAccessProperties.java @@ -16,6 +16,7 @@ public class FileAccessProperties implements InitializingBean { private boolean blockBrowserNavigation = true; private String publicBaseUrl = ""; private List allowedOrigins = new ArrayList<>(); + private List publicImageRefererPrefixes = new ArrayList<>(); private Map clients = new LinkedHashMap<>(); private LegacyDart legacyDart = new LegacyDart(); private Upload upload = new Upload(); @@ -72,8 +73,22 @@ public class FileAccessProperties implements InitializingBean { } } catch (IllegalArgumentException e) { throw new IllegalStateException("Invalid file-access.public-base-url"); } } + if (publicImageRefererPrefixes == null) throw new IllegalStateException("Invalid public image Referer prefixes"); + for (String prefix : publicImageRefererPrefixes) { + try { + java.net.URI uri = java.net.URI.create(prefix); + String path = uri.getRawPath(); + if (uri.getHost() == null || !("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + || uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null + || path == null || path.length() <= 1 || !path.endsWith("/") || path.contains("%") + || !path.equals(uri.normalize().getRawPath())) throw new IllegalArgumentException(); + } catch (RuntimeException e) { throw new IllegalStateException("Invalid public image Referer prefix"); } + } if (enforcing()) { for (String app : List.of("admin", "h5", "mini", "flutter", "service", "package")) { + // Some deployments retain an old H5 and only allow its image Referer prefix. + // An omitted H5 key does not authorize other H5 requests. + if (app.equals("h5") && !clients.containsKey(app)) continue; Client client = clients.get(app); if (client == null || !client.getActiveKid().matches("[a-zA-Z0-9_-]{1,32}") || !validKey(client.getKeys().get(client.getActiveKid()))) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java index ea9391760..6d0225463 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileAccessGuard.java @@ -10,6 +10,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Clock; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -21,6 +23,7 @@ import java.util.regex.Pattern; public class FileAccessGuard { private static final Set APPS = Set.of("admin", "h5", "mini", "flutter", "service", "package"); private static final List FIELDS = List.of("fileName", "appId", "kid", "ts", "sign"); + private static final Set IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "ico"); private static final Pattern DART = Pattern.compile("^Dart/\\d+\\.\\d+(?:\\.\\d+)? \\(dart:io\\)$"); private final FileAccessProperties properties; private final MeterRegistry meters; @@ -53,6 +56,7 @@ public class FileAccessGuard { if (properties.isBlockBrowserNavigation() && browserNavigation(request)) { return new Decision(403, "browser_navigation", 0); } + if (publicImageReferer(request, file)) return new Decision(200, "public_image_referer", 300); boolean signed = FIELDS.stream().filter(f -> !f.equals("fileName")).anyMatch(f -> request.getParameterValues(f) != null); if (!signed) { String ua = Objects.toString(request.getHeader("User-Agent"), ""); @@ -81,6 +85,32 @@ public class FileAccessGuard { return new Decision(200, "signed", Math.max(0, Math.min(300, issuedAt + properties.ttl(app) - now))); } + private boolean publicImageReferer(HttpServletRequest request, String file) { + if (!Set.of("GET", "HEAD").contains(Objects.toString(request.getMethod(), ""))) return false; + int dot = file.lastIndexOf('.'); + if (dot < 0 || !IMAGE_EXTENSIONS.contains(file.substring(dot + 1).toLowerCase(Locale.ROOT))) return false; + List headers = Collections.list(request.getHeaders("Referer")); + if (headers.size() != 1 || properties.getPublicImageRefererPrefixes().isEmpty()) return false; + try { + URI referer = URI.create(headers.get(0)); + if (referer.getHost() == null || referer.getUserInfo() != null || referer.getRawFragment() != null + || referer.getPath().contains("\\")) return false; + // Check both raw and decoded normalized paths to reject prefix lookalikes and traversal. + String path = new URI(null, null, referer.getPath(), null).normalize().getPath(); + for (String configured : properties.getPublicImageRefererPrefixes()) { + URI prefix = URI.create(configured); + if (prefix.getScheme().equalsIgnoreCase(referer.getScheme()) + && prefix.getHost().equalsIgnoreCase(referer.getHost()) && port(prefix) == port(referer) + && referer.getRawPath().startsWith(prefix.getRawPath()) && path.startsWith(prefix.getPath())) return true; + } + } catch (IllegalArgumentException | URISyntaxException ignored) { } + return false; + } + + private static int port(URI uri) { + return uri.getPort() >= 0 ? uri.getPort() : "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + private boolean browserNavigation(HttpServletRequest request) { return Collections.list(request.getHeaders("Sec-Fetch-Mode")).stream().anyMatch(value -> "navigate".equalsIgnoreCase(value.trim())) || Collections.list(request.getHeaders("Sec-Fetch-Dest")).stream() @@ -93,7 +123,7 @@ public class FileAccessGuard { public boolean authorize(HttpServletRequest request, HttpServletResponse response) throws IOException { // A cached response must not be reused for a document navigation of the same URL. - response.setHeader("Vary", "User-Agent, Sec-Fetch-Mode, Sec-Fetch-Dest"); + response.setHeader("Vary", "User-Agent, Referer, Sec-Fetch-Mode, Sec-Fetch-Dest"); Decision decision; try { decision = evaluate(request); } catch (RuntimeException e) { decision = new Decision(500, "configuration_error", 0); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java new file mode 100644 index 000000000..60f37bb1f --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessMobileTest.java @@ -0,0 +1,112 @@ +package com.bsd.pjcloud.admin.service.file; + +import com.bsd.pjcloud.admin.controller.SysFileController; +import com.bsd.pjcloud.admin.service.SysFileService; +import com.bsd.pjcloud.common.core.file.FileAccessProperties; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import static com.bsd.pjcloud.admin.service.file.FileUploadTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class FileAccessMobileTest { + static final String MOBILE = "https://www.pjzhhq.cn/mobile/"; + FileAccessProperties settings() { + var p = properties("enforce"); + p.getClients().remove("h5"); + p.setPublicImageRefererPrefixes(List.of(MOBILE)); + return p; + } + MockHttpServletRequest image(String method, String file, String referer) { + var request = new MockHttpServletRequest(method, "/sys-file/oss/file"); + request.setParameter("fileName", file); + if (referer != null) request.addHeader("Referer", referer); + return request; + } + @Test void oldH5ImagesNeedNoAppIdSignatureOrClientUpdate() { + var guard = new FileAccessGuard(settings(), null, CLOCK); + for (String method : List.of("GET", "HEAD")) for (String extension : List.of("png", "JPG", "jpeg", "gif", "webp", "bmp", "svg", "ico")) { + for (String referer : List.of(MOBILE, MOBILE + "index.html", MOBILE + "nested/page?from=app", "https://www.pjzhhq.cn:443/mobile/")) { + assertEquals("public_image_referer", guard.evaluate(image(method, "photo." + extension, referer)).reason()); + } + } + var existing = image("GET", "photo.png", MOBILE); + existing.setParameter("appId", "h5"); existing.setParameter("kid", "old"); + existing.setParameter("ts", "1"); existing.setParameter("sign", "old-cached-signature"); + assertEquals(200, guard.evaluate(existing).status()); + } + @Test void onlyTheExactConfiguredOriginAndPathPrefixBypassImageSigning() { + var guard = new FileAccessGuard(settings(), null, CLOCK); + assertEquals(403, guard.evaluate(image("GET", "a.png", null)).status()); + for (String referer : List.of("https://www.pjzhhq.cn/", "https://www.pjzhhq.cn/mch/mch-manage/index", + "https://www.pjzhhq.cn/mobile", "https://www.pjzhhq.cn/mobile-other/", "https://stgl.pj.gov.cn/mobile/", + "http://www.pjzhhq.cn/mobile/", "https://www.pjzhhq.cn:444/mobile/", "https://www.pjzhhq.cn.evil.example/mobile/", + "https://www.pjzhhq.cn@evil.example/mobile/", "https://user@www.pjzhhq.cn/mobile/", + "https://www.pjzhhq.cn/?next=" + MOBILE, "https://www.pjzhhq.cn/mobile/../mch/", + "https://www.pjzhhq.cn/mobile/%2e%2e/mch/", "https://www.pjzhhq.cn/mobile%2findex.html", "not a URL")) { + assertEquals(403, guard.evaluate(image("GET", "a.png", referer)).status(), referer); + } + var duplicate = image("GET", "a.png", MOBILE); duplicate.addHeader("Referer", MOBILE); + assertEquals(403, guard.evaluate(duplicate).status()); + var p = settings(); p.setPublicImageRefererPrefixes(List.of()); + assertEquals(403, new FileAccessGuard(p, null, CLOCK).evaluate(image("GET", "a.png", MOBILE)).status()); + } + @Test void navigationDocumentsMalformedRequestsAndUploadsKeepTheirExistingRestrictions() { + var p = settings(); var guard = new FileAccessGuard(p, null, CLOCK); + for (String file : List.of("a.pdf", "a.apk", "a.log", "a.zip", "a.mp4")) { + assertEquals(403, guard.evaluate(image("GET", file, MOBILE)).status()); + } + for (String destination : List.of("document", "iframe", "frame")) { + var request = image("GET", "a.png", MOBILE); request.addHeader("Sec-Fetch-Dest", destination); + assertEquals("browser_navigation", guard.evaluate(request).reason()); + } + var request = image("GET", "a.png", MOBILE); request.addHeader("Sec-Fetch-Mode", "navigate"); + assertEquals(403, guard.evaluate(request).status()); + request = image("GET", "a.png", MOBILE); request.addParameter("fileName", "b.png"); + assertEquals(400, guard.evaluate(request).status()); + assertEquals(400, guard.evaluate(image("GET", "a.png\n", MOBILE)).status()); + var upload = new FileUploadGuard(p, (a, k, n, ttl) -> true, null, CLOCK); + assertEquals(403, upload.evaluate(image("POST", "a.png", MOBILE), "upload").status()); + var missingKey = signed("upload", "h5", NOW, NONCE); missingKey.addHeader("Referer", MOBILE); + assertEquals("unknown_upload_key", upload.evaluate(missingKey, "upload").reason()); + } + @Test void anOmittedH5KeyIsOptionalButIncompleteConfiguredKeysAndInvalidPrefixesFailClosed() throws Exception { + assertDoesNotThrow(() -> settings().validate()); + for (String required : List.of("admin", "mini", "flutter", "service", "package")) { + var p = settings(); p.getClients().remove(required); assertThrows(IllegalStateException.class, p::validate); + } + var incomplete = settings(); incomplete.getClients().put("h5", new FileAccessProperties.Client()); + assertThrows(IllegalStateException.class, incomplete::validate); + var raw = image("GET", "a.png", null); raw.setParameter("appId", "h5"); + assertEquals(400, new FileAccessGuard(settings(), null, CLOCK).evaluate(raw).status()); + for (String prefix : List.of("https://www.pjzhhq.cn/", MOBILE + "#/", MOBILE + "?x=1", "https://user@www.pjzhhq.cn/mobile/", "https://www.pjzhhq.cn/mobile", "https://www.pjzhhq.cn/a/../mobile/")) { + var p = settings(); p.setPublicImageRefererPrefixes(List.of(prefix)); assertThrows(IllegalStateException.class, p::validate); + } + var p = settings(); p.setPublicImageRefererPrefixes(List.of(MOBILE + "#/")); + var response = new MockHttpServletResponse(); + assertFalse(new FileAccessGuard(p, null, CLOCK).authorize(image("GET", "a.png", MOBILE), response)); + assertEquals(500, response.getStatus()); + } + @Test void getAndHeadUsePrivateRefererVaryingCachingAndOtherRequestsDoNotReachStorage() throws Exception { + var p = settings(); var service = mock(SysFileService.class); var meters = new SimpleMeterRegistry(); + var guard = new FileAccessGuard(p, meters, CLOCK); + var mvc = MockMvcBuilders.standaloneSetup(new SysFileController(service, guard, p, mock(FileUploadGuard.class))).build(); + mvc.perform(get("/sys-file/oss/file").param("fileName", "a.png")).andExpect(status().isForbidden()); + mvc.perform(get("/sys-file/oss/file").param("fileName", "a.pdf").header("Referer", MOBILE)).andExpect(status().isForbidden()); + verifyZeroInteractions(service); + for (var request : List.of(get("/sys-file/oss/file"), head("/sys-file/oss/file"))) { + mvc.perform(request.param("fileName", "a.png").header("Referer", MOBILE)) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "private, max-age=300")) + .andExpect(header().string("Vary", "User-Agent, Referer, Sec-Fetch-Mode, Sec-Fetch-Dest")); + } + verify(service, times(2)).getFile(eq("a.png"), any()); + assertEquals(2.0, meters.get("file.access.requests").tag("outcome", "public_image_referer").counter().count()); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessTest.java index c101fb8a2..b8bfe66ac 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileAccessTest.java @@ -68,7 +68,7 @@ class FileAccessTest { request.addHeader("Sec-Fetch-Dest", destination); var response = new MockHttpServletResponse(); assertTrue(guard.authorize(request, response)); - assertEquals("User-Agent, Sec-Fetch-Mode, Sec-Fetch-Dest", response.getHeader("Vary")); + assertEquals("User-Agent, Referer, Sec-Fetch-Mode, Sec-Fetch-Dest", response.getHeader("Vary")); } assertEquals(200, guard.evaluate(signed("a.apk", "package", NOW)).status()); assertEquals(200, guard.evaluate(signed("a.jpg", "service", NOW)).status()); diff --git a/scripts/generate-file-access-config.py b/scripts/generate-file-access-config.py index 7135d9f0f..391b33351 100644 --- a/scripts/generate-file-access-config.py +++ b/scripts/generate-file-access-config.py @@ -54,6 +54,7 @@ def main(): policy = config.setdefault('file-access', {}) defaults = {'mode': 'observe', 'block-browser-navigation': True, 'ttl-seconds': 1800, 'package-ttl-seconds': 86400, 'future-skew-seconds': 300, + 'public-image-referer-prefixes': [], 'legacy-dart': {'enabled': True, 'extensions': ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico']}} for name, value in defaults.items(): policy.setdefault(name, value) diff --git a/scripts/tests/test_generate_file_access_config.py b/scripts/tests/test_generate_file_access_config.py index 61b68fe8c..cc755ebe5 100644 --- a/scripts/tests/test_generate_file_access_config.py +++ b/scripts/tests/test_generate_file_access_config.py @@ -77,6 +77,7 @@ class SharedFileAccessConfigurationTest(unittest.TestCase): policy = json.loads((self.secret_dir / 'config.yml').read_text())['file-access'] self.assertEqual(policy['mode'], 'observe') self.assertTrue(policy['block-browser-navigation']) + self.assertEqual(policy['public-image-referer-prefixes'], []) self.assertEqual(policy['upload']['ttl-seconds'], 300) self.assertEqual(policy['upload']['max-file-bytes'], 200 * 1024 * 1024) self.assertTrue({'jpg', 'pdf', 'apk', 'docx', 'xlsx', 'zip', 'mp4', 'txt'}.issubset(policy['upload']['allowed-extensions'])) @@ -87,11 +88,13 @@ class SharedFileAccessConfigurationTest(unittest.TestCase): registry = (self.secret_dir / 'keys.json').read_bytes() path = self.secret_dir / 'config.yml' config = json.loads(path.read_text()) + config['file-access']['public-image-referer-prefixes'] = ['https://files.example/mobile/'] config['file-access']['upload'] = {'ttl-seconds': 120, 'max-file-bytes': 1024, 'allowed-extensions': ['png', 'pdf']} path.write_text(json.dumps(config)) self.run_script() policy = json.loads(path.read_text())['file-access']['upload'] + self.assertEqual(json.loads(path.read_text())['file-access']['public-image-referer-prefixes'], ['https://files.example/mobile/']) self.assertEqual(policy['ttl-seconds'], 120) self.assertEqual(policy['max-file-bytes'], 1024) self.assertEqual(policy['allowed-extensions'], ['png', 'pdf'])