diff --git a/Cargo.lock b/Cargo.lock index 8fd39481..7485c4db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.4", + "socket2 0.6.5", "time", "tracing", "url", @@ -861,7 +861,7 @@ dependencies = [ [[package]] name = "aster_forge_actix_middleware" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "actix-governor", "actix-web", @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "aster_forge_actix_observability" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "actix-web", "aster_forge_metrics", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "aster_forge_alloc" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "tikv-jemalloc-ctl", "tracing", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "aster_forge_api" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "chrono", "serde", @@ -910,7 +910,7 @@ dependencies = [ [[package]] name = "aster_forge_api_docs_macros" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "proc-macro2", "quote", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "aster_forge_audit" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_db", "aster_forge_mail", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "aster_forge_cache" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_runtime", "async-trait", @@ -949,7 +949,7 @@ dependencies = [ [[package]] name = "aster_forge_config" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_utils", "async-trait", @@ -968,7 +968,7 @@ dependencies = [ [[package]] name = "aster_forge_crypto" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "argon2 0.5.3", "rand_core 0.6.4", @@ -979,7 +979,7 @@ dependencies = [ [[package]] name = "aster_forge_db" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_api", "aster_forge_config", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "aster_forge_external_auth" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_crypto", "aster_forge_utils", @@ -1022,7 +1022,7 @@ dependencies = [ [[package]] name = "aster_forge_file_classification" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "sea-orm", "serde", @@ -1033,7 +1033,7 @@ dependencies = [ [[package]] name = "aster_forge_logging" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "serde", "tracing-appender", @@ -1043,7 +1043,7 @@ dependencies = [ [[package]] name = "aster_forge_mail" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_runtime", "aster_forge_tasks", @@ -1061,7 +1061,7 @@ dependencies = [ [[package]] name = "aster_forge_metrics" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_alloc", "aster_forge_runtime", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "aster_forge_panic" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "chrono", ] @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "aster_forge_runtime" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_utils", "async-trait", @@ -1101,7 +1101,7 @@ dependencies = [ [[package]] name = "aster_forge_tasks" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "aster_forge_runtime", "aster_forge_utils", @@ -1121,7 +1121,7 @@ dependencies = [ [[package]] name = "aster_forge_utils" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "http 1.4.2", "httpdate", @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "aster_forge_validation" version = "0.1.0" -source = "git+https://github.com/AsterCommunity/AsterForge#4a83e82036e6719e9b4f257b2321f22458bcd13f" +source = "git+https://github.com/AsterCommunity/AsterForge#fdb595d7f62e94d9c1f9d9a043c053c38e7c3ec5" dependencies = [ "thiserror 2.0.18", "unicode-normalization", @@ -2459,7 +2459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ "digest 0.10.7", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] @@ -3330,7 +3330,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -3978,7 +3978,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4419,7 +4419,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -4448,7 +4448,7 @@ dependencies = [ "percent-encoding", "quoted_printable", "rustls", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tokio-rustls", "url", @@ -5972,7 +5972,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -6011,7 +6011,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -6192,7 +6192,7 @@ dependencies = [ "rustls-native-certs", "ryu", "sha1_smol", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tokio-rustls", "tokio-util", @@ -7398,9 +7398,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -7457,9 +7457,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -7467,18 +7467,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spinning_top" @@ -8159,7 +8159,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -8298,7 +8298,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.4", + "socket2 0.6.5", "sync_wrapper", "tokio", "tokio-stream", diff --git a/docs/config/runtime.md b/docs/config/runtime.md index b5088dde..ef34c015 100644 --- a/docs/config/runtime.md +++ b/docs/config/runtime.md @@ -173,6 +173,11 @@ - 浏览器页面和 AsterDrive 不在同一个域名下 - 想让别的站点在浏览器里直接调用 AsterDrive +- 浏览器扩展需要直接访问 WebDAV 或 API + +`允许的跨域来源` 是由完整 origin 组成的数组,每个输入框填写一项,例如 `https://panel.example.com` 或 `chrome-extension://扩展ID`。当前支持 HTTP(S) 站点,以及 Chrome/Edge、Firefox 和 Safari Web Extension 的扩展来源。配置扩展时应填写完整扩展 ID,不要按协议放行所有扩展。 + +CORS 默认关闭,白名单也默认为空;此时服务端不会添加 CORS 响应头或拦截携带 `Origin` 的请求。启用 CORS 后,只有白名单中的精确来源会被允许。单独填写 `*` 可以允许任意来源,但不能同时开启跨域凭据。 ::: tip 同站部署一般不用动 大多数"前端页面和接口都在同一个站点里"的部署不需要碰这里。 diff --git a/docs/en/config/runtime.md b/docs/en/config/runtime.md index 2164b2c7..320b3a28 100644 --- a/docs/en/config/runtime.md +++ b/docs/en/config/runtime.md @@ -173,6 +173,11 @@ Change it only in these scenarios: - The browser page and AsterDrive are not under the same domain - You want another site to call AsterDrive directly from the browser +- A browser extension needs to access WebDAV or the API directly + +`Allowed CORS origins` is an array of complete origins, with one item per input. Examples include `https://panel.example.com` and `chrome-extension://extension-id`. HTTP(S) sites and Chrome/Edge, Firefox, and Safari Web Extension origins are supported. For an extension, configure its complete extension ID instead of allowing every extension by scheme. + +CORS is disabled by default and the allowlist is empty. In that state, the server neither adds CORS response headers nor rejects requests merely because they carry an `Origin` header. Once CORS is enabled, only exact allowlist matches are accepted. A single `*` item permits any origin, but it cannot be combined with cross-origin credentials. ::: tip Same-site deployments usually do not need changes Most deployments where "frontend pages and APIs are on the same site" do not need to touch this group. diff --git a/frontend-panel/src/components/admin/settings/useAdminSettingsData.test.ts b/frontend-panel/src/components/admin/settings/useAdminSettingsData.test.ts index c39fd62f..d82d0456 100644 --- a/frontend-panel/src/components/admin/settings/useAdminSettingsData.test.ts +++ b/frontend-panel/src/components/admin/settings/useAdminSettingsData.test.ts @@ -298,13 +298,14 @@ function createBaseConfigs() { function createCorsConfigs(overrides?: { allowCredentials?: string; - allowedOrigins?: string; + allowedOrigins?: string[]; }): SystemConfig[] { return [ createConfig({ category: "network", key: "cors_allowed_origins", - value: overrides?.allowedOrigins ?? "*", + value: overrides?.allowedOrigins ?? ["*"], + value_type: "string_array", }), createConfig({ category: "network", @@ -639,7 +640,7 @@ describe("useAdminSettingsData", () => { mockState.listConfigs.mockResolvedValueOnce({ items: createCorsConfigs({ allowCredentials: "true", - allowedOrigins: "https://panel.example.com", + allowedOrigins: ["https://panel.example.com"], }), }); @@ -648,7 +649,7 @@ describe("useAdminSettingsData", () => { await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { - result.current.updateDraftValue("cors_allowed_origins", "*"); + result.current.updateDraftValue("cors_allowed_origins", ["*"]); }); expect(result.current.hasValidationError).toBe(true); @@ -682,10 +683,9 @@ describe("useAdminSettingsData", () => { expect(result.current.hasValidationError).toBe(true); act(() => { - result.current.updateDraftValue( - "cors_allowed_origins", + result.current.updateDraftValue("cors_allowed_origins", [ "https://panel.example.com", - ); + ]); }); expect(result.current.hasValidationError).toBe(false); @@ -695,10 +695,9 @@ describe("useAdminSettingsData", () => { await result.current.handleSaveAll(); }); - expect(mockState.setConfig).toHaveBeenCalledWith( - "cors_allowed_origins", + expect(mockState.setConfig).toHaveBeenCalledWith("cors_allowed_origins", [ "https://panel.example.com", - ); + ]); expect(mockState.setConfig).toHaveBeenCalledWith( "cors_allow_credentials", "true", diff --git a/frontend-panel/src/components/admin/settings/useAdminSettingsData.ts b/frontend-panel/src/components/admin/settings/useAdminSettingsData.ts index 8b95891f..30b12f5b 100644 --- a/frontend-panel/src/components/admin/settings/useAdminSettingsData.ts +++ b/frontend-panel/src/components/admin/settings/useAdminSettingsData.ts @@ -620,9 +620,9 @@ export function useAdminSettingsData({ const configValidationErrors = useMemo(() => { const errors = new Map(); - const allowedOrigins = configValueToString( + const allowedOrigins = configValueToStringArray( getDraftValueForKey(CORS_ALLOWED_ORIGINS_KEY), - ).trim(); + ).map((origin) => origin.trim()); const allowCredentials = configValueToString( getDraftValueForKey(CORS_ALLOW_CREDENTIALS_KEY), @@ -632,7 +632,7 @@ export function useAdminSettingsData({ getDraftValueForKey(AUTH_EMAIL_CODE_LOGIN_ENABLED_KEY), ).trim() === "true"; - if (allowCredentials && allowedOrigins === "*") { + if (allowCredentials && allowedOrigins.includes("*")) { const message = t("cors_wildcard_credentials_validation_error"); errors.set(CORS_ALLOWED_ORIGINS_KEY, message); errors.set(CORS_ALLOW_CREDENTIALS_KEY, message); diff --git a/frontend-panel/src/i18n/locales/en/admin/settings-network.json b/frontend-panel/src/i18n/locales/en/admin/settings-network.json index c5c17ef0..0b98930a 100644 --- a/frontend-panel/src/i18n/locales/en/admin/settings-network.json +++ b/frontend-panel/src/i18n/locales/en/admin/settings-network.json @@ -2,7 +2,7 @@ "settings_item_cors_enabled_label": "Enable CORS handling", "settings_item_cors_enabled_desc": "Controls whether AsterDrive actively processes cross-origin browser requests. When disabled, the server skips all CORS headers and enforcement.", "settings_item_cors_allowed_origins_label": "Allowed cross-origin origins", - "settings_item_cors_allowed_origins_desc": "List the browser origins that may access AsterDrive cross-origin, separated by commas. Empty means the server returns no CORS headers and the browser blocks cross-origin access naturally. \"*\" allows any origin.", + "settings_item_cors_allowed_origins_desc": "Add each complete browser origin as a separate item. HTTP(S) sites and chrome-extension, moz-extension, and safari-web-extension origins are supported. Empty means the server returns no CORS headers and does not enforce CORS; a single \"*\" item allows any origin.", "settings_item_cors_allow_credentials_label": "Allow cross-origin credentials", "settings_item_cors_allow_credentials_desc": "Controls whether CORS responses include Access-Control-Allow-Credentials. Do not combine this with an allowed-origins value of \"*\".", "settings_item_cors_max_age_secs_label": "Preflight cache duration", diff --git a/frontend-panel/src/i18n/locales/zh/admin/settings-network.json b/frontend-panel/src/i18n/locales/zh/admin/settings-network.json index 319efec6..24d04733 100644 --- a/frontend-panel/src/i18n/locales/zh/admin/settings-network.json +++ b/frontend-panel/src/i18n/locales/zh/admin/settings-network.json @@ -2,7 +2,7 @@ "settings_item_cors_enabled_label": "启用 CORS 处理", "settings_item_cors_enabled_desc": "控制 AsterDrive 是否主动处理浏览器跨域请求。关闭后服务端不会返回任何 CORS 响应头,也不会执行 CORS 拦截。", "settings_item_cors_allowed_origins_label": "允许的跨域来源", - "settings_item_cors_allowed_origins_desc": "填写允许浏览器跨域访问的来源列表,使用英文逗号分隔。留空表示服务端不返回 CORS 响应头,由浏览器自然阻止跨域访问;\"*\" 表示允许任意来源。", + "settings_item_cors_allowed_origins_desc": "填写允许浏览器跨域访问的完整来源,每个来源单独一项。支持 HTTP(S) 站点以及 chrome-extension、moz-extension、safari-web-extension 浏览器扩展来源。留空表示服务端不返回 CORS 响应头且不执行 CORS 拦截;单独填写 \"*\" 表示允许任意来源。", "settings_item_cors_allow_credentials_label": "允许跨域携带凭据", "settings_item_cors_allow_credentials_desc": "控制 CORS 响应是否返回 Access-Control-Allow-Credentials。开启时不要把允许来源配置成 \"*\"。", "settings_item_cors_max_age_secs_label": "预检缓存时间", diff --git a/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx b/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx index 327184ee..b323ce36 100644 --- a/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx +++ b/frontend-panel/src/pages/admin/AdminSettingsPage.test.tsx @@ -648,6 +648,7 @@ function createTemplateVariableGroup( } function getMockConfigCategory(key: string) { + if (key.startsWith("cors_")) return "network"; if (key.startsWith("auth")) return "auth"; if (key.startsWith("custom")) return "custom"; if (key.startsWith("mail_template_")) return "mail.template"; @@ -663,6 +664,9 @@ function getMockConfigSource(key: string): ConfigSource { } function getMockConfigValueType(key: string): ConfigValueType { + if (key === "cors_allowed_origins") { + return "string_array"; + } if (key === "audit_log_recorded_actions") { return "string_enum_set"; } @@ -1114,8 +1118,8 @@ describe("AdminSettingsPage", () => { createConfig({ category: "network", key: "cors_allowed_origins", - value: "*", - value_type: "string", + value: ["*"], + value_type: "string_array", }), createConfig({ category: "network", @@ -1129,7 +1133,7 @@ describe("AdminSettingsPage", () => { createSchemaItem({ category: "network", key: "cors_allowed_origins", - value_type: "string", + value_type: "string_array", }), createSchemaItem({ category: "network", @@ -1158,8 +1162,8 @@ describe("AdminSettingsPage", () => { createConfig({ category: "network", key: "cors_allowed_origins", - value: "*", - value_type: "string", + value: ["*"], + value_type: "string_array", }), createConfig({ category: "network", @@ -1173,7 +1177,7 @@ describe("AdminSettingsPage", () => { createSchemaItem({ category: "network", key: "cors_allowed_origins", - value_type: "string", + value_type: "string_array", }), createSchemaItem({ category: "network", @@ -1212,10 +1216,9 @@ describe("AdminSettingsPage", () => { "true", ); }); - expect(mockState.setConfig).toHaveBeenCalledWith( - "cors_allowed_origins", + expect(mockState.setConfig).toHaveBeenCalledWith("cors_allowed_origins", [ "https://panel.example.com", - ); + ]); }); it("keeps email-code MFA disabled until mail delivery settings are complete", async () => { diff --git a/src/api/middleware/cors/mod.rs b/src/api/middleware/cors/mod.rs index 7c93ef8f..377f329a 100644 --- a/src/api/middleware/cors/mod.rs +++ b/src/api/middleware/cors/mod.rs @@ -17,7 +17,12 @@ pub fn runtime_cors() -> ForgeRuntimeCors { RuntimeCorsConfig::new(resolve_runtime_policy, is_cors_exempt_path, map_cors_error) .allowed_methods(ALLOWED_METHODS.iter().copied()) .allowed_headers(ALLOWED_HEADERS.iter().copied()) - .exposed_headers(EXPOSE_HEADERS.iter().copied()), + .exposed_headers(EXPOSE_HEADERS.iter().copied()) + .additional_origin_schemes( + crate::config::cors::BROWSER_EXTENSION_ORIGIN_SCHEMES + .iter() + .copied(), + ), ) } diff --git a/src/config/cors.rs b/src/config/cors.rs index 49efc5ef..4e044189 100644 --- a/src/config/cors.rs +++ b/src/config/cors.rs @@ -16,6 +16,9 @@ pub const DEFAULT_CORS_ENABLED: bool = false; pub const DEFAULT_CORS_ALLOW_CREDENTIALS: bool = false; pub const DEFAULT_CORS_MAX_AGE_SECS: u64 = 3600; +pub(crate) const BROWSER_EXTENSION_ORIGIN_SCHEMES: &[&str] = + &["chrome-extension", "moz-extension", "safari-web-extension"]; + pub fn runtime_cors_policy(runtime_config: &RuntimeConfig) -> RuntimeCorsPolicy { let enabled = match runtime_config.get(CORS_ENABLED_KEY) { Some(raw) => match parse_bool_str(&raw) { @@ -119,11 +122,24 @@ pub fn normalize_enabled_config_value(value: &str) -> Result { pub fn normalize_allowed_origins_config_value(value: &str) -> Result { let parsed = parse_allowed_origins_value(value)?; - Ok(match parsed { - CorsAllowedOrigins::None => String::new(), - CorsAllowedOrigins::Any => "*".to_string(), - CorsAllowedOrigins::List(origins) => origins.join(","), - }) + serialize_allowed_origins(&parsed) +} + +pub fn normalize_existing_allowed_origins_config_value(value: &str) -> Result { + if value.trim().starts_with('[') { + return normalize_allowed_origins_config_value(value); + } + + let legacy_origins = value + .split(',') + .map(str::trim) + .filter(|origin| !origin.is_empty()) + .map(ToString::to_string) + .collect::>(); + let legacy_json = serde_json::to_string(&legacy_origins).map_aster_err_with(|| { + AsterError::internal_error("failed to serialize legacy CORS origins") + })?; + normalize_allowed_origins_config_value(&legacy_json) } pub fn normalize_allow_credentials_config_value(value: &str) -> Result { @@ -149,16 +165,20 @@ pub fn parse_allowed_origins_value(value: &str) -> Result { return Ok(CorsAllowedOrigins::None); } + let configured = serde_json::from_str::>(trimmed).map_aster_err_with(|| { + AsterError::validation_error("cors_allowed_origins must be a JSON array of origin strings") + })?; + let mut origins = BTreeSet::new(); let mut wildcard = false; - for raw_origin in trimmed.split(',') { + for raw_origin in configured { let origin = raw_origin.trim(); if origin.is_empty() { continue; } - let normalized = normalize_origin(origin, true)?; + let normalized = normalize_cors_origin(origin, true)?; if normalized == "*" { wildcard = true; } else { @@ -181,7 +201,29 @@ pub fn parse_allowed_origins_value(value: &str) -> Result { } } +fn serialize_allowed_origins(origins: &CorsAllowedOrigins) -> Result { + let origins = match origins { + CorsAllowedOrigins::None => Vec::new(), + CorsAllowedOrigins::Any => vec!["*".to_string()], + CorsAllowedOrigins::List(origins) => origins.clone(), + }; + serde_json::to_string(&origins) + .map_aster_err_with(|| AsterError::internal_error("failed to serialize CORS origins")) +} + pub fn normalize_origin(origin: &str, allow_wildcard: bool) -> Result { + normalize_origin_with_schemes(origin, allow_wildcard, &[]) +} + +pub fn normalize_cors_origin(origin: &str, allow_wildcard: bool) -> Result { + normalize_origin_with_schemes(origin, allow_wildcard, BROWSER_EXTENSION_ORIGIN_SCHEMES) +} + +fn normalize_origin_with_schemes( + origin: &str, + allow_wildcard: bool, + additional_schemes: &[&str], +) -> Result { let trimmed = origin.trim(); if trimmed.is_empty() { return Err(AsterError::validation_error("origin cannot be empty")); @@ -201,9 +243,9 @@ pub fn normalize_origin(origin: &str, allow_wildcard: bool) -> Result { )) })?; - if scheme != "http" && scheme != "https" { + if scheme != "http" && scheme != "https" && !additional_schemes.contains(&scheme) { return Err(AsterError::validation_error(format!( - "CORS origin must use http or https: '{trimmed}'" + "origin scheme is not supported: '{trimmed}'" ))); } @@ -271,8 +313,10 @@ mod tests { CORS_ALLOW_CREDENTIALS_KEY, CORS_ALLOWED_ORIGINS_KEY, CORS_ENABLED_KEY, CORS_MAX_AGE_SECS_KEY, DEFAULT_CORS_ENABLED, DEFAULT_CORS_MAX_AGE_SECS, normalize_allow_credentials_config_value, normalize_allowed_origins_config_value, - normalize_enabled_config_value, normalize_max_age_config_value, normalize_origin, - parse_allowed_origins_value, runtime_cors_policy, validate_runtime_cors_combination, + normalize_cors_origin, normalize_enabled_config_value, + normalize_existing_allowed_origins_config_value, normalize_max_age_config_value, + normalize_origin, parse_allowed_origins_value, runtime_cors_policy, + validate_runtime_cors_combination, }; fn config_model(key: &str, value: &str) -> system_config::Model { @@ -296,7 +340,7 @@ mod tests { #[test] fn parse_empty_origins_as_none() { assert_eq!( - parse_allowed_origins_value(" ").unwrap(), + parse_allowed_origins_value("[]").unwrap(), CorsAllowedOrigins::None ); } @@ -313,16 +357,35 @@ mod tests { fn parse_origin_list_deduplicates_and_sorts() { assert_eq!( normalize_allowed_origins_config_value( - "https://b.example.com, https://a.example.com/, https://b.example.com" + r#"["https://b.example.com", "https://a.example.com/", "https://b.example.com"]"# + ) + .unwrap(), + r#"["https://a.example.com","https://b.example.com"]"# + ); + } + + #[test] + fn normalize_existing_origin_list_migrates_legacy_comma_format() { + assert_eq!( + normalize_existing_allowed_origins_config_value( + "https://b.example.com, chrome-extension://iikmkjmpaadaobahmlepeloendndfphd, https://b.example.com" ) .unwrap(), - "https://a.example.com,https://b.example.com" + r#"["chrome-extension://iikmkjmpaadaobahmlepeloendndfphd","https://b.example.com"]"# + ); + assert_eq!( + normalize_existing_allowed_origins_config_value("").unwrap(), + "[]" + ); + assert_eq!( + normalize_existing_allowed_origins_config_value("*").unwrap(), + r#"["*"]"# ); } #[test] fn reject_mixed_wildcard_and_explicit_origins() { - let err = parse_allowed_origins_value("*,https://app.example.com").unwrap_err(); + let err = parse_allowed_origins_value(r#"["*","https://app.example.com"]"#).unwrap_err(); assert!( err.message().contains(CORS_ALLOWED_ORIGINS_KEY) || err.message().contains("explicit origins") @@ -357,7 +420,31 @@ mod tests { #[test] fn normalize_origin_rejects_non_http_scheme() { let err = normalize_origin("ftp://app.example.com", false).unwrap_err(); - assert!(err.message().contains("must use http or https")); + assert!(err.message().contains("scheme is not supported")); + } + + #[test] + fn normalize_cors_origin_accepts_browser_extension_schemes() { + for origin in [ + "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd", + "moz-extension://4fbe19d7-2d0e-4a7f-b708-31957a9f48e9", + "safari-web-extension://com.example.backup", + ] { + assert_eq!(normalize_cors_origin(origin, false).unwrap(), origin); + assert!(normalize_origin(origin, false).is_err()); + } + } + + #[test] + fn normalize_cors_origin_rejects_unrelated_schemes() { + for origin in [ + "ftp://app.example.com", + "file://localhost", + "custom-extension://example", + ] { + let err = normalize_cors_origin(origin, false).unwrap_err(); + assert!(err.message().contains("scheme is not supported")); + } } #[test] @@ -415,7 +502,7 @@ mod tests { runtime_config.apply(config_model(CORS_ENABLED_KEY, "true")); runtime_config.apply(config_model( CORS_ALLOWED_ORIGINS_KEY, - "https://app.example.com/path", + r#"["https://app.example.com/path"]"#, )); let policy = runtime_cors_policy(&runtime_config); @@ -429,7 +516,7 @@ mod tests { fn runtime_policy_wildcard_with_credentials_downgrades_to_safe_policy() { let runtime_config = RuntimeConfig::new(); runtime_config.apply(config_model(CORS_ENABLED_KEY, "true")); - runtime_config.apply(config_model(CORS_ALLOWED_ORIGINS_KEY, "*")); + runtime_config.apply(config_model(CORS_ALLOWED_ORIGINS_KEY, r#"["*"]"#)); runtime_config.apply(config_model(CORS_ALLOW_CREDENTIALS_KEY, "true")); let policy = runtime_cors_policy(&runtime_config); diff --git a/src/config/definitions.rs b/src/config/definitions.rs index 73b32c07..b2374e65 100644 --- a/src/config/definitions.rs +++ b/src/config/definitions.rs @@ -820,12 +820,12 @@ pub static ALL_CONFIGS: &[ConfigDef] = &[ key: CORS_ALLOWED_ORIGINS_KEY, label_i18n_key: "settings_item_cors_allowed_origins_label", description_i18n_key: "settings_item_cors_allowed_origins_desc", - value_type: ConfigValueType::String, - default_fn: || String::new(), + value_type: ConfigValueType::StringArray, + default_fn: empty_string_array_default, requires_restart: false, is_sensitive: false, category: CONFIG_CATEGORY_NETWORK, - description: "Comma-separated CORS origin whitelist. Empty = skip CORS headers and let the browser block cross-origin access, '*' = allow any origin", + description: "Exact CORS origin whitelist as a JSON string array. Empty = skip CORS headers and enforcement, ['*'] = allow any origin", normalize_fn: Some(normalize_cors_allowed_origins), ..aster_forge_config::ConfigDefinition::private_system() }, diff --git a/src/config/system_config.rs b/src/config/system_config.rs index d7b8817c..ab02b220 100644 --- a/src/config/system_config.rs +++ b/src/config/system_config.rs @@ -300,7 +300,7 @@ mod tests { let lookup = HashMap::from([("cors_allow_credentials".to_string(), "true".to_string())]); let err = CONFIG_REGISTRY - .normalize_value(&lookup, "cors_allowed_origins", "*") + .normalize_value(&lookup, "cors_allowed_origins", r#"["*"]"#) .unwrap_err(); assert!( err.to_string() diff --git a/src/db/repository/config_repo.rs b/src/db/repository/config_repo.rs index ab677aab..a625a46b 100644 --- a/src/db/repository/config_repo.rs +++ b/src/db/repository/config_repo.rs @@ -226,6 +226,12 @@ where }) .await?; } + normalize_existing_product_config( + db, + crate::config::cors::CORS_ALLOWED_ORIGINS_KEY, + normalize_existing_cors_allowed_origins_config_value, + ) + .await?; normalize_existing_product_config(db, apps::PREVIEW_APPS_CONFIG_KEY, |active| { normalize_existing_preview_apps_config_value(active) }) @@ -248,6 +254,26 @@ where Ok(()) } +fn normalize_existing_cors_allowed_origins_config_value(active: &mut system_config::ActiveModel) { + let existing = match &active.value { + sea_orm::ActiveValue::Set(value) | sea_orm::ActiveValue::Unchanged(value) => value.clone(), + sea_orm::ActiveValue::NotSet => return, + }; + + match crate::config::cors::normalize_existing_allowed_origins_config_value(&existing) { + Ok(normalized) if normalized != existing => active.value = Set(normalized), + Ok(_) => {} + Err(error) => { + tracing::warn!( + error = %error, + key = crate::config::cors::CORS_ALLOWED_ORIGINS_KEY, + "failed to migrate legacy CORS origins; clearing invalid whitelist" + ); + active.value = Set("[]".to_string()); + } + } +} + fn normalize_existing_media_processing_registry_config_value( active: &mut system_config::ActiveModel, ) { @@ -509,6 +535,61 @@ mod tests { assert!(images.enabled); } + #[tokio::test] + async fn ensure_defaults_migrates_legacy_cors_origin_values_to_string_arrays() { + let db = setup_db().await; + ensure_system_value_if_missing( + &db, + crate::config::cors::CORS_ALLOWED_ORIGINS_KEY, + "https://b.example.com,chrome-extension://iikmkjmpaadaobahmlepeloendndfphd,https://b.example.com", + ) + .await + .expect("legacy CORS config insert should succeed"); + + ensure_defaults_with_env(&db, &|_| None) + .await + .expect("ensure_defaults should migrate legacy CORS origins"); + + let stored = find_by_key(&db, crate::config::cors::CORS_ALLOWED_ORIGINS_KEY) + .await + .expect("CORS config lookup should succeed") + .expect("CORS config should exist"); + assert_eq!( + stored.value_type, + aster_forge_config::ConfigValueType::StringArray + ); + assert_eq!( + stored.value, + r#"["chrome-extension://iikmkjmpaadaobahmlepeloendndfphd","https://b.example.com"]"# + ); + } + + #[tokio::test] + async fn ensure_defaults_clears_invalid_legacy_cors_origin_values() { + let db = setup_db().await; + ensure_system_value_if_missing( + &db, + crate::config::cors::CORS_ALLOWED_ORIGINS_KEY, + "ftp://backup.example.com", + ) + .await + .expect("invalid legacy CORS config insert should succeed"); + + ensure_defaults_with_env(&db, &|_| None) + .await + .expect("ensure_defaults should safely migrate invalid CORS origins"); + + let stored = find_by_key(&db, crate::config::cors::CORS_ALLOWED_ORIGINS_KEY) + .await + .expect("CORS config lookup should succeed") + .expect("CORS config should exist"); + assert_eq!( + stored.value_type, + aster_forge_config::ConfigValueType::StringArray + ); + assert_eq!(stored.value, "[]"); + } + #[tokio::test] async fn ensure_defaults_restores_missing_preview_builtins_without_overwriting_existing_apps() { let db = setup_db().await; diff --git a/tests/test_cors.rs b/tests/test_cors.rs index c9db426f..571c8d0f 100644 --- a/tests/test_cors.rs +++ b/tests/test_cors.rs @@ -83,6 +83,101 @@ async fn test_runtime_cors_defaults_passthrough_cross_origin_actual_request() { ); } +#[actix_web::test] +async fn test_runtime_cors_defaults_passthrough_browser_extension_webdav_request() { + let state = common::setup().await; + let app = create_test_app_with_cors!(state); + + let req = test::TestRequest::get() + .uri("/webdav/") + .insert_header(( + header::ORIGIN, + "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd", + )) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), 401); + assert!( + resp.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); +} + +#[actix_web::test] +async fn test_runtime_cors_allows_configured_browser_extension_origin() { + const EXTENSION_ORIGIN: &str = "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd"; + + let state = common::setup().await; + let app = create_test_app_with_cors!(state); + let (token, _) = register_and_login!(app); + enable_cors!(app, token); + let resp = set_config!( + app, + token, + "cors_allowed_origins", + serde_json::json!([EXTENSION_ORIGIN]) + ); + assert_eq!(resp.status(), 200); + let resp = set_config!(app, token, "cors_allow_credentials", "true"); + assert_eq!(resp.status(), 200); + + let req = test::TestRequest::default() + .method(actix_web::http::Method::OPTIONS) + .uri("/webdav/") + .insert_header((header::ORIGIN, EXTENSION_ORIGIN)) + .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "PROPFIND")) + .insert_header(( + header::ACCESS_CONTROL_REQUEST_HEADERS, + "authorization, depth", + )) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 204); + assert_eq!( + resp.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .unwrap() + .to_str() + .unwrap(), + EXTENSION_ORIGIN + ); + assert_eq!( + resp.headers() + .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS) + .unwrap(), + "true" + ); + + let req = test::TestRequest::default() + .method(actix_web::http::Method::OPTIONS) + .uri("/webdav/") + .insert_header(( + header::ORIGIN, + "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )) + .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "PROPFIND")) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 403); +} + +#[actix_web::test] +async fn test_runtime_cors_admin_rejects_legacy_string_origin_value() { + let state = common::setup().await; + let app = create_test_app_with_cors!(state); + let (token, _) = register_and_login!(app); + + let resp = set_config!( + app, + token, + "cors_allowed_origins", + "https://app.example.com" + ); + assert_eq!(resp.status(), 400); +} + #[actix_web::test] async fn test_runtime_cors_same_origin_origin_header_is_not_blocked() { let state = common::setup().await; @@ -114,12 +209,15 @@ async fn test_runtime_cors_hot_reload_updates_whitelist_and_max_age() { .uri("/api/v1/admin/config/cors_allowed_origins") .insert_header(("Cookie", common::access_cookie_header(&token))) .insert_header(common::csrf_header_for(&token)) - .set_json(serde_json::json!({ "value": "https://app.example.com/" })) + .set_json(serde_json::json!({ "value": ["https://app.example.com/"] })) .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: Value = test::read_body_json(resp).await; - assert_eq!(body["data"]["value"], "https://app.example.com"); + assert_eq!( + body["data"]["value"], + serde_json::json!(["https://app.example.com"]) + ); let req = test::TestRequest::default() .method(actix_web::http::Method::OPTIONS) @@ -151,7 +249,7 @@ async fn test_runtime_cors_hot_reload_updates_whitelist_and_max_age() { .uri("/api/v1/admin/config/cors_allowed_origins") .insert_header(("Cookie", common::access_cookie_header(&token))) .insert_header(common::csrf_header_for(&token)) - .set_json(serde_json::json!({ "value": "https://dashboard.example.com" })) + .set_json(serde_json::json!({ "value": ["https://dashboard.example.com"] })) .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 200); @@ -210,7 +308,7 @@ async fn test_runtime_cors_credentials_require_explicit_origin_list() { .uri("/api/v1/admin/config/cors_allowed_origins") .insert_header(("Cookie", common::access_cookie_header(&token))) .insert_header(common::csrf_header_for(&token)) - .set_json(serde_json::json!({ "value": "*" })) + .set_json(serde_json::json!({ "value": ["*"] })) .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 200); @@ -243,7 +341,7 @@ async fn test_runtime_cors_adds_credentials_header_for_allowed_origin() { .uri("/api/v1/admin/config/cors_allowed_origins") .insert_header(("Cookie", common::access_cookie_header(&token))) .insert_header(common::csrf_header_for(&token)) - .set_json(serde_json::json!({ "value": "https://panel.example.com" })) + .set_json(serde_json::json!({ "value": ["https://panel.example.com"] })) .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 200); @@ -291,13 +389,18 @@ async fn test_runtime_cors_admin_normalizes_and_deduplicates_origin_list() { app, token, "cors_allowed_origins", - " https://b.example.com/, , https://a.example.com, https://b.example.com " + serde_json::json!([ + "https://b.example.com/", + "", + "https://a.example.com", + "https://b.example.com" + ]) ); assert_eq!(resp.status(), 200); let body: Value = test::read_body_json(resp).await; assert_eq!( body["data"]["value"], - "https://a.example.com,https://b.example.com" + serde_json::json!(["https://a.example.com", "https://b.example.com"]) ); } @@ -311,7 +414,7 @@ async fn test_runtime_cors_admin_rejects_origin_with_path() { app, token, "cors_allowed_origins", - "https://app.example.com/path" + serde_json::json!(["https://app.example.com/path"]) ); assert_eq!(resp.status(), 400); let body: Value = test::read_body_json(resp).await; @@ -333,7 +436,7 @@ async fn test_runtime_cors_admin_rejects_mixed_wildcard_and_explicit_origins() { app, token, "cors_allowed_origins", - "*,https://app.example.com" + serde_json::json!(["*", "https://app.example.com"]) ); assert_eq!(resp.status(), 400); let body: Value = test::read_body_json(resp).await; @@ -347,7 +450,7 @@ async fn test_runtime_cors_wildcard_preflight_returns_star_and_vary_headers() { let (token, _) = register_and_login!(app); enable_cors!(app, token); - let resp = set_config!(app, token, "cors_allowed_origins", "*"); + let resp = set_config!(app, token, "cors_allowed_origins", serde_json::json!(["*"])); assert_eq!(resp.status(), 200); let req = test::TestRequest::default() @@ -399,7 +502,7 @@ async fn test_runtime_cors_disallowed_actual_request_returns_403_with_vary() { app, token, "cors_allowed_origins", - "https://allowed.example.com" + serde_json::json!(["https://allowed.example.com"]) ); assert_eq!(resp.status(), 200); @@ -423,7 +526,7 @@ async fn test_runtime_cors_preflight_rejects_unknown_method() { app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); @@ -448,7 +551,7 @@ async fn test_runtime_cors_preflight_rejects_unknown_header() { app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); @@ -474,7 +577,7 @@ async fn test_runtime_cors_preflight_invalid_request_headers_returns_400() { app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); @@ -509,7 +612,7 @@ async fn test_runtime_cors_allowed_actual_request_sets_expose_and_vary_headers() app, token, "cors_allowed_origins", - "https://panel.example.com" + serde_json::json!(["https://panel.example.com"]) ); assert_eq!(resp.status(), 200); @@ -540,7 +643,7 @@ async fn test_runtime_cors_invalid_origin_header_returns_400() { app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); @@ -563,7 +666,7 @@ async fn test_runtime_cors_wildcard_actual_request_returns_star_and_expose_heade let (token, _) = register_and_login!(app); enable_cors!(app, token); - let resp = set_config!(app, token, "cors_allowed_origins", "*"); + let resp = set_config!(app, token, "cors_allowed_origins", serde_json::json!(["*"])); assert_eq!(resp.status(), 200); let req = test::TestRequest::get() @@ -606,7 +709,7 @@ async fn test_runtime_cors_preflight_max_age_zero_is_reflected() { app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); let resp = set_config!(app, token, "cors_max_age_secs", "0"); @@ -669,13 +772,13 @@ async fn test_runtime_cors_rejects_setting_wildcard_after_credentials_enabled() app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); let resp = set_config!(app, token, "cors_allow_credentials", "true"); assert_eq!(resp.status(), 200); - let resp = set_config!(app, token, "cors_allowed_origins", "*"); + let resp = set_config!(app, token, "cors_allowed_origins", serde_json::json!(["*"])); assert_eq!(resp.status(), 400); let body: Value = test::read_body_json(resp).await; assert!( @@ -715,6 +818,7 @@ async fn test_runtime_cors_schema_contains_network_defaults() { .find(|item| item["key"] == "cors_allowed_origins") .unwrap(); assert_eq!(allowed_origins["category"], "network"); + assert_eq!(allowed_origins["value_type"], "string_array"); assert!(allowed_origins.get("default_value").is_none()); assert_eq!(allowed_origins["requires_restart"], false); @@ -742,7 +846,7 @@ async fn test_runtime_cors_clearing_whitelist_disables_cross_origin_immediately( app, token, "cors_allowed_origins", - "https://app.example.com" + serde_json::json!(["https://app.example.com"]) ); assert_eq!(resp.status(), 200); @@ -757,7 +861,7 @@ async fn test_runtime_cors_clearing_whitelist_disables_cross_origin_immediately( .contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) ); - let resp = set_config!(app, token, "cors_allowed_origins", ""); + let resp = set_config!(app, token, "cors_allowed_origins", serde_json::json!([])); assert_eq!(resp.status(), 200); let req = test::TestRequest::get() @@ -784,7 +888,7 @@ async fn test_runtime_cors_disabling_credentials_removes_header_immediately() { app, token, "cors_allowed_origins", - "https://panel.example.com" + serde_json::json!(["https://panel.example.com"]) ); assert_eq!(resp.status(), 200); let resp = set_config!(app, token, "cors_allow_credentials", "true"); @@ -850,11 +954,14 @@ async fn test_runtime_cors_allows_request_headers_case_insensitively() { app, token, "cors_allowed_origins", - "HTTPS://APP.EXAMPLE.COM/" + serde_json::json!(["HTTPS://APP.EXAMPLE.COM/"]) ); assert_eq!(resp.status(), 200); let body: Value = test::read_body_json(resp).await; - assert_eq!(body["data"]["value"], "https://app.example.com"); + assert_eq!( + body["data"]["value"], + serde_json::json!(["https://app.example.com"]) + ); let req = test::TestRequest::default() .method(actix_web::http::Method::OPTIONS) @@ -944,7 +1051,7 @@ async fn test_runtime_cors_public_site_url_still_requires_whitelist_match() { app, token, "cors_allowed_origins", - "https://api.example.com" + serde_json::json!(["https://api.example.com"]) ); assert_eq!(resp.status(), 200);