Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/527.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`MediaProxy` no longer serves media whose source event has since been redacted.
43 changes: 43 additions & 0 deletions spec/unit/media-proxy.spec.cts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,47 @@ describe("MediaProxy", function() {
expect(data.endDt).toBeGreaterThanOrEqual(now + 60 * 1000);
expect(data.endDt).toBeLessThanOrEqual(now + 61 * 1000);
});

it('can decode a media url containing the source room and event', async () => {
const mxc = 'mxc://example.com/some_media';
const url = await mediaProxy.generateMediaUrl(mxc, {
roomId: '!room:example.com',
eventId: '$event:example.com',
});
const token = url.pathname.slice('/my-cs-path/v1/media/download/'.length);
const data = await mediaProxy.verifyMediaToken(token);
expect('mxc://' + data.mxc).toBe(mxc);
expect(data.roomId).toBe('!room:example.com');
expect(data.eventId).toBe('$event:example.com');
});

it('refuses to serve media whose source event has been redacted', async () => {
const matrixClient = (mediaProxy as unknown as { matrixClient: MatrixClient }).matrixClient;
spyOn(matrixClient, 'getEvent').and.resolveTo({
unsigned: { redacted_because: { type: 'm.room.redaction' } },
} as never);
const mxc = 'mxc://example.com/some_media';
const url = await mediaProxy.generateMediaUrl(mxc, {
roomId: '!room:example.com',
eventId: '$event:example.com',
});
const token = url.pathname.slice('/my-cs-path/v1/media/download/'.length);
const req = { params: { mediaToken: token } } as unknown as Parameters<typeof mediaProxy.onMediaRequest>[0];
const res = {} as unknown as Parameters<typeof mediaProxy.onMediaRequest>[1];
await expectAsync(mediaProxy.onMediaRequest(req, res)).toBeRejectedWithError(/no longer available/);
});

it('refuses to serve media if the source event cannot be verified', async () => {
const matrixClient = (mediaProxy as unknown as { matrixClient: MatrixClient }).matrixClient;
spyOn(matrixClient, 'getEvent').and.rejectWith(new Error('M_NOT_FOUND'));
const mxc = 'mxc://example.com/some_media';
const url = await mediaProxy.generateMediaUrl(mxc, {
roomId: '!room:example.com',
eventId: '$event:example.com',
});
const token = url.pathname.slice('/my-cs-path/v1/media/download/'.length);
const req = { params: { mediaToken: token } } as unknown as Parameters<typeof mediaProxy.onMediaRequest>[0];
const res = {} as unknown as Parameters<typeof mediaProxy.onMediaRequest>[1];
await expectAsync(mediaProxy.onMediaRequest(req, res)).toBeRejectedWithError(/Could not verify/);
});
});
96 changes: 83 additions & 13 deletions src/components/media-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ const log = new Logger('MediaProxy');
interface MediaMetadata {
endDt?: number;
mxc: string;
/**
* The room the media was sent in, if known. Used to check that the
* source event has not since been redacted before proxying the media.
*/
roomId?: string;
/**
* The event the media was sent in, if known. Used to check that the
* source event has not since been redacted before proxying the media.
*/
eventId?: string;
}

interface Opts {
Expand Down Expand Up @@ -71,44 +81,49 @@ export class MediaProxy {
}

async getMediaToken(metadata: MediaMetadata) {
// V1 token format:
// V2 token format:
// - At offset zero: a single byte, numeric int, indicating a token version.
// Version 0 is reserved for future use, for the remote possibility we run out of versions in an int8 :)
// - At offset 1: the SHA-512 HMAC signature of the payload (64 bytes)
// - At offset 65: MediaMetadata.endDt, encoded as a Big-Endian double (matching JS' `number` type).
// An undefined endDt is encoded as a -1. 8 bytes.
// - At offset 73: the MXC of the media content, until the end of the buffer.
// - At offset 73: the room ID the media was sent in (may be empty), NUL terminated.
// - Following that: the event ID the media was sent in (may be empty), NUL terminated.
// - Following that: the MXC of the media content, until the end of the buffer.
// The payload, for the purpose of generating the signature,
// is the byte-encoded endDt concatenated with the byte-encoded MXC.
// is the byte-encoded endDt concatenated with the byte-encoded roomId, eventId and MXC.
const version = Buffer.allocUnsafe(1);
version.writeInt8(1);
version.writeInt8(2);

const dt = Buffer.allocUnsafe(8);
dt.writeDoubleBE(metadata.endDt ?? -1);

const nul = Buffer.from([0]);
const roomIdBuf = Buffer.from(metadata.roomId ?? '');
const eventIdBuf = Buffer.from(metadata.eventId ?? '');
const mxcBuf = Buffer.from(metadata.mxc);

const payload = Buffer.concat([dt, mxcBuf]);
const payload = Buffer.concat([dt, roomIdBuf, nul, eventIdBuf, nul, mxcBuf]);
const sig = Buffer.from(await subtleCrypto.sign(ALGORITHM, this.opts.signingKey, payload));

const token = Buffer.concat([version, sig, dt, mxcBuf]);
const token = Buffer.concat([version, sig, payload]);
return token.toString('base64url');
}

async verifyMediaToken(token: string): Promise<MediaMetadata> {
const buf = Buffer.from(token, 'base64url');
let cursor = 0;
const version = buf.readInt8(cursor++);
if (version !== 1) {
if (version !== 1 && version !== 2) {
throw new ApiError(`Unrecognized version of media token (${version})`, ErrCode.BadValue);
}

const sig = buf.subarray(cursor, cursor += 64);
const dtBuf = buf.subarray(cursor, cursor += 8);
const mxcBuf = buf.subarray(cursor);
const payload = buf.subarray(cursor);

try {
if (!subtleCrypto.verify(ALGORITHM, this.opts.signingKey, Buffer.concat([dtBuf, mxcBuf]), sig)) {
if (!subtleCrypto.verify(ALGORITHM, this.opts.signingKey, Buffer.concat([dtBuf, payload]), sig)) {
throw new Error('Signature did not match');
}
}
Expand All @@ -117,17 +132,50 @@ export class MediaProxy {
}

const dt = dtBuf.readDoubleBE();
const endDt = dt === -1 ? undefined : dt;

// Older tokens (v1) only ever encoded the MXC URI, with no way to tie
// the media back to the event it was sent from.
if (version === 1) {
return {
mxc: payload.toString(),
endDt,
};
}

const firstNul = payload.indexOf(0);
const secondNul = payload.indexOf(0, firstNul + 1);
const roomId = payload.subarray(0, firstNul).toString() || undefined;
const eventId = payload.subarray(firstNul + 1, secondNul).toString() || undefined;
const mxc = payload.subarray(secondNul + 1).toString();

return {
mxc: mxcBuf.toString(),
endDt: dt === -1 ? undefined : dt,
mxc,
endDt,
roomId,
eventId,
};
}


public async generateMediaUrl(mxc: string): Promise<URL> {
/**
* Generate a public URL for some media.
* @param mxc The mxc:// URI of the media to be proxied.
* @param sourceEvent The room and event the media was sent in, if known.
* When provided, the proxy will refuse to serve the
* media once the source event has been redacted.
*/
public async generateMediaUrl(
mxc: string, sourceEvent?: { roomId: string, eventId: string }
): Promise<URL> {
const endDt = this.opts.ttl ? Date.now() + this.opts.ttl : undefined;
// Remove cruft
const token = await this.getMediaToken({ endDt, mxc: mxc.replace('mxc://', '') });
const token = await this.getMediaToken({
endDt,
mxc: mxc.replace('mxc://', ''),
roomId: sourceEvent?.roomId,
eventId: sourceEvent?.eventId,
});
const { pathname, origin } = this.opts.publicUrl;
const slash = pathname.endsWith('/') ? '' : '/';
const path = new URL(
Expand All @@ -146,6 +194,9 @@ export class MediaProxy {
if (metadata.endDt && metadata.endDt < Date.now()) {
throw new ApiError('Access to the media you requested has now expired.', ErrCode.NotFound);
}
if (metadata.roomId && metadata.eventId) {
await this.checkEventNotRedacted(metadata.roomId, metadata.eventId);
}
// Cache from this point onwards.
// Extract the media from the event.
const mxcMatch = metadata.mxc.match(new RegExp('^([^/]+)/(.+)$'));
Expand Down Expand Up @@ -184,6 +235,25 @@ export class MediaProxy {
});
}

/**
* Ensure that the event the media was sent in has not since been redacted
* (e.g. because it was found to contain abusive or otherwise unwanted
* content). Throws an ApiError if the media should no longer be served.
*/
private async checkEventNotRedacted(roomId: string, eventId: string): Promise<void> {
let event;
try {
event = await this.matrixClient.getEvent(roomId, eventId);
}
catch (ex) {
log.warn(`Failed to fetch event ${eventId} in ${roomId} while checking for redaction`, ex);
throw new ApiError('Could not verify that the media is still available', ErrCode.NotFound);
}
if (event.unsigned.redacted_because) {
throw new ApiError('This media is no longer available', ErrCode.NotFound);
}
}

private getHealth(req: Request, res: Response) {
res.send({ok: true});
}
Expand Down