diff --git a/apps/xftp-server/static/xftp-web-bundle/index.js b/apps/xftp-server/static/xftp-web-bundle/index.js index 1d41088c8b..718276baca 100644 --- a/apps/xftp-server/static/xftp-web-bundle/index.js +++ b/apps/xftp-server/static/xftp-web-bundle/index.js @@ -674,14 +674,26 @@ function getDescriptionServers(fd) { const servers = []; for (const chunk of fd.chunks) { for (const replica of chunk.replicas) { - if (!seen.has(replica.server)) { - seen.add(replica.server); - servers.push(parseXFTPServer(replica.server)); - } + addServer(servers, seen, replica.server); } } return servers; } +function getDownloadServers(fd) { + const seen = /* @__PURE__ */ new Set(); + const servers = []; + for (const chunk of fd.chunks) { + const replica = chunk.replicas[0]; + if (replica) addServer(servers, seen, replica.server); + } + return servers; +} +function addServer(servers, seen, address) { + if (!seen.has(address)) { + seen.add(address); + servers.push(parseXFTPServer(address)); + } +} function serverOrigin(server) { return server.port === "443" ? `https://${server.host}` : `https://${server.host}:${server.port}`; } @@ -11428,7 +11440,7 @@ function initDownload(app, hash) { app.innerHTML = `

${t("invalidLink", "Invalid or corrupted link.")}

`; return; } - const wrongServer = !getDescriptionServers(fd).map((s) => s.host).includes(window.location.hostname); + const wrongServer = !getDownloadServers(fd).map((s) => s.host).includes(window.location.hostname); const size = fd.redirect ? fd.redirect.size : fd.size; app.innerHTML = `
diff --git a/tests/XFTPWebTests.hs b/tests/XFTPWebTests.hs index c9a98eef1c..e153e8ebc6 100644 --- a/tests/XFTPWebTests.hs +++ b/tests/XFTPWebTests.hs @@ -14,7 +14,7 @@ module XFTPWebTests (xftpWebTests) where import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar) -import Control.Monad (replicateM, when) +import Control.Monad (forM_, replicateM, when) import Crypto.Error (throwCryptoError) import qualified Crypto.PubKey.Curve25519 as X25519 import qualified Crypto.PubKey.Ed25519 as Ed25519 @@ -170,6 +170,7 @@ jsOut expr = "process.stdout.write(Buffer.from(" <> expr <> "));" xftpWebTests :: IO () -> Spec xftpWebTests dbCleanup = do + xftpWebSourceHygieneTests distExists <- runIO $ doesDirectoryExist (xftpWebDir <> "/dist") if distExists then do @@ -193,6 +194,19 @@ xftpWebTests dbCleanup = do it "skipped (run 'cd xftp-web && npm install && npm run build' first)" $ pendingWith "TS project not compiled" +xftpWebSourceHygieneTests :: Spec +xftpWebSourceHygieneTests = describe "source hygiene" $ + it "gates XFTP web downloads on first-replica servers" $ do + let expectations = + [ ("xftp-web/src/protocol/address.ts", "export function getDownloadServers"), + ("xftp-web/web/download.ts", "getDownloadServers(fd)"), + ("apps/xftp-server/static/xftp-web-bundle/index.js", "function getDownloadServers"), + ("apps/xftp-server/static/xftp-web-bundle/index.js", "getDownloadServers(fd)") + ] + forM_ expectations $ \(path, marker) -> do + contents <- B.readFile path + contents `shouldSatisfy` B.isInfixOf marker + -- ── protocol/encoding ────────────────────────────────────────────── tsEncodingTests :: Spec @@ -2828,6 +2842,26 @@ tsAddressTests = describe "protocol/address" $ do <> jsOut "new TextEncoder().encode(s.host + ':' + s.port)" result `shouldBe` "host1.com:5000" + it "getDownloadServers returns only first replicas" $ do + result <- + callNode $ + impAddr + <> "const kh = 'LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=';\ + \const fd = {chunks: [\ + \ {replicas: [\ + \ {server: `xftp://${kh}@first.example:443`},\ + \ {server: `xftp://${kh}@allowed.example:443`}\ + \ ]},\ + \ {replicas: [\ + \ {server: `xftp://${kh}@second.example:443`},\ + \ {server: `xftp://${kh}@allowed.example:443`}\ + \ ]}\ + \]};\ + \const allHosts = Addr.getDescriptionServers(fd).map(s => s.host).join(',');\ + \const downloadHosts = Addr.getDownloadServers(fd).map(s => s.host).join(',');" + <> jsOut "new TextEncoder().encode(allHosts + '|' + downloadHosts)" + result `shouldBe` "first.example,allowed.example,second.example|first.example,second.example" + -- ── integration ─────────────────────────────────────────────────── tsIntegrationTests :: IO () -> Spec diff --git a/xftp-web/src/protocol/address.ts b/xftp-web/src/protocol/address.ts index e3eb27fd8e..a341b965e4 100644 --- a/xftp-web/src/protocol/address.ts +++ b/xftp-web/src/protocol/address.ts @@ -59,15 +59,30 @@ export function getDescriptionServers(fd: {chunks: {replicas: {server: string}[] const servers: XFTPServer[] = [] for (const chunk of fd.chunks) { for (const replica of chunk.replicas) { - if (!seen.has(replica.server)) { - seen.add(replica.server) - servers.push(parseXFTPServer(replica.server)) - } + addServer(servers, seen, replica.server) } } return servers } +// Extract unique XFTP servers that downloadFileRaw will actually contact. +export function getDownloadServers(fd: {chunks: {replicas: {server: string}[]}[]}): XFTPServer[] { + const seen = new Set() + const servers: XFTPServer[] = [] + for (const chunk of fd.chunks) { + const replica = chunk.replicas[0] + if (replica) addServer(servers, seen, replica.server) + } + return servers +} + +function addServer(servers: XFTPServer[], seen: Set, address: string) { + if (!seen.has(address)) { + seen.add(address) + servers.push(parseXFTPServer(address)) + } +} + // Build an HTTPS origin from an XFTP server address. export function serverOrigin(server: XFTPServer): string { return server.port === "443" ? `https://${server.host}` : `https://${server.host}:${server.port}` diff --git a/xftp-web/web/download.ts b/xftp-web/web/download.ts index da7d8a38ff..ea2532bd16 100644 --- a/xftp-web/web/download.ts +++ b/xftp-web/web/download.ts @@ -6,7 +6,7 @@ import { newXFTPAgent, closeXFTPAgent, decodeDescriptionURI, downloadFileRaw } from '../src/agent.js' -import {getDescriptionServers} from '../src/protocol/address.js' +import {getDownloadServers} from '../src/protocol/address.js' import {XFTPPermanentError} from '../src/client.js' const DECRYPT_WEIGHT = 0.15 @@ -22,7 +22,7 @@ export function initDownload(app: HTMLElement, hash: string) { return } - const wrongServer = !getDescriptionServers(fd).map(s => s.host).includes(window.location.hostname) + const wrongServer = !getDownloadServers(fd).map(s => s.host).includes(window.location.hostname) const size = fd.redirect ? fd.redirect.size : fd.size app.innerHTML = `