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 = `