fix: show inline images whose part name is a path

This commit is contained in:
Linus Rath
2026-10-29 02:06:12 +01:00
parent 913b37661f
commit 4d545f8f58
5 changed files with 30 additions and 2 deletions
+9
View File
@@ -69,6 +69,15 @@ describe('blob operations', () => {
expect(url).toContain('my%20file%20(1).pdf');
});
// A part named like a path: an encoded slash (%2F) in the URL path is
// refused or decoded by common reverse proxies, so inline images went
// blank (webmail #1371).
it('keeps only the last path component of a part name', () => {
expect(getDownloadUrl('b1', '/images/banner.jpg', 'image/jpeg')).toContain('/b1/banner.jpg?');
expect(getDownloadUrl('b1', 'C:\\pics\\logo.png', 'image/png')).toContain('/b1/logo.png?');
expect(getDownloadUrl('b1', 'dir/', 'image/png')).toContain('/b1/download?');
});
});
});
@@ -194,6 +194,8 @@ describe('JMAPClient hardening', () => {
const session = await client.connect('https://mail.example.com', 'user', 'pass');
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream');
// A part name that is a path keeps its last component (webmail #1371).
expect(client.getBlobDownloadUrl('b1', '/images/n.jpg')).toBe('https://mail.example.com/download/acc-1/b1/n.jpg?type=application%2Foctet-stream');
});
it('refetches the final URL when a redirect dropped the Authorization header', async () => {
+2 -1
View File
@@ -2,6 +2,7 @@ import { File } from 'expo-file-system';
import type * as LegacyFileSystemTypes from 'expo-file-system/legacy';
import { jmapClient } from './jmap-client';
import { batched } from './jmap-result';
import { downloadNameSegment } from './download-name';
import { CAPABILITIES } from './types';
import { getClientCertAlias, secureFetch } from '../lib/client-cert';
@@ -268,7 +269,7 @@ export function getDownloadUrl(
return session.downloadUrl
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{name}', encodeURIComponent(downloadNameSegment(name)))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
}
+15
View File
@@ -0,0 +1,15 @@
/**
* The {name} segment of a JMAP download URL (RFC 8620 §6.2). Port of the
* webmail's lib/jmap/download-name.ts (#1371).
*
* The server only uses it for the Content-Disposition filename, but mailers
* sometimes put a path in a part's name ("/images/banner.jpg"). Percent-
* encoded, its slashes become %2F, which Apache (without AllowEncodedSlashes
* NoDecode), Traefik and nginx proxy_pass with a URI reject or decode, so the
* download failed and inline cid: images showed as blank. Keep the last path
* component instead.
*/
export function downloadNameSegment(name: string | undefined): string {
const base = (name ?? '').split(/[/\\]/).pop()?.trim();
return base || 'download';
}
+2 -1
View File
@@ -15,6 +15,7 @@ import {
type OAuthTokenSource,
} from '../lib/oauth';
import { FirstTouchGate } from './first-touch-gate';
import { downloadNameSegment } from './download-name';
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
@@ -1347,7 +1348,7 @@ export class JMAPClient {
return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(accountId ?? this.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name || 'download'))
.replace('{name}', encodeURIComponent(downloadNameSegment(name)))
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
}