fix: Make sure all URI characters are correctly encoded

This commit is contained in:
Joachim Van Herwegen 2020-10-13 17:07:35 +02:00
parent e8e96b903d
commit e85ca622da
5 changed files with 49 additions and 29 deletions

View File

@ -1,7 +1,7 @@
import type { TLSSocket } from 'tls'; import type { TLSSocket } from 'tls';
import { format } from 'url'; import { format } from 'url';
import type { HttpRequest } from '../../server/HttpRequest'; import type { HttpRequest } from '../../server/HttpRequest';
import { toCanonicalUrl } from '../../util/Util'; import { toCanonicalUriPath } from '../../util/Util';
import type { ResourceIdentifier } from '../representation/ResourceIdentifier'; import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
import { TargetExtractor } from './TargetExtractor'; import { TargetExtractor } from './TargetExtractor';
@ -23,12 +23,12 @@ export class BasicTargetExtractor extends TargetExtractor {
throw new Error('Missing host.'); throw new Error('Missing host.');
} }
const isHttps = input.connection && (input.connection as TLSSocket).encrypted; const isHttps = input.connection && (input.connection as TLSSocket).encrypted;
const url = format({ const path = format({
protocol: `http${isHttps ? 's' : ''}`, protocol: `http${isHttps ? 's' : ''}`,
host: input.headers.host, host: toCanonicalUriPath(input.headers.host),
pathname: input.url, pathname: toCanonicalUriPath(input.url),
}); });
return { path: toCanonicalUrl(url) }; return { path };
} }
} }

View File

@ -6,7 +6,7 @@ import { APPLICATION_OCTET_STREAM, TEXT_TURTLE } from '../util/ContentTypes';
import { ConflictHttpError } from '../util/errors/ConflictHttpError'; import { ConflictHttpError } from '../util/errors/ConflictHttpError';
import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError'; import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError';
import { trimTrailingSlashes } from '../util/Util'; import { decodeUriPathComponents, encodeUriPathComponents, trimTrailingSlashes } from '../util/Util';
import type { FileIdentifierMapper, ResourceLink } from './FileIdentifierMapper'; import type { FileIdentifierMapper, ResourceLink } from './FileIdentifierMapper';
const { join: joinPath, normalize: normalizePath } = posix; const { join: joinPath, normalize: normalizePath } = posix;
@ -137,7 +137,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
let relative = filePath.slice(this.rootFilepath.length); let relative = filePath.slice(this.rootFilepath.length);
if (isContainer) { if (isContainer) {
return { return {
identifier: { path: encodeURI(this.baseRequestURI + relative) }, identifier: { path: this.baseRequestURI + encodeUriPathComponents(relative) },
filePath, filePath,
}; };
} }
@ -150,7 +150,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
} }
return { return {
identifier: { path: encodeURI(this.baseRequestURI + relative) }, identifier: { path: this.baseRequestURI + encodeUriPathComponents(relative) },
filePath, filePath,
contentType, contentType,
}; };
@ -201,7 +201,7 @@ export class ExtensionBasedMapper implements FileIdentifierMapper {
if (!identifier.path.startsWith(this.baseRequestURI)) { if (!identifier.path.startsWith(this.baseRequestURI)) {
throw new NotFoundHttpError(); throw new NotFoundHttpError();
} }
return decodeURI(identifier.path).slice(this.baseRequestURI.length); return decodeUriPathComponents(identifier.path.slice(this.baseRequestURI.length));
} }
/** /**

View File

@ -68,14 +68,19 @@ export const pipeStreamsAndErrors = <T extends Writable>(readable: Readable, des
}; };
/** /**
* Converts a URL string to the "canonical" version that should be used internally for consistency. * Converts a URI path to the canonical version by splitting on slashes,
* Decodes all percent encodings and then makes sure only the necessary characters are encoded again. * decoding any percent-based encodings,
* and then encoding any special characters.
*/ */
export const toCanonicalUrl = (url: string): string => { export const toCanonicalUriPath = (path: string): string => path.split('/').map((part): string =>
const match = /(\w+:\/\/[^/]+\/)(.*)/u.exec(url); encodeURIComponent(decodeURIComponent(part))).join('/');
if (!match) {
throw new UnsupportedHttpError(`Invalid URL ${url}`); /**
} * Decodes all components of a URI path.
const [ , domain, path ] = match; */
return encodeURI(domain + path.split('/').map(decodeURIComponent).join('/')); export const decodeUriPathComponents = (path: string): string => path.split('/').map(decodeURIComponent).join('/');
};
/**
* Encodes all (non-slash) special characters in a URI path.
*/
export const encodeUriPathComponents = (path: string): string => path.split('/').map(encodeURIComponent).join('/');

View File

@ -26,8 +26,14 @@ describe('A BasicTargetExtractor', (): void => {
)).resolves.toEqual({ path: 'https://test.com/url' }); )).resolves.toEqual({ path: 'https://test.com/url' });
}); });
it('decodes relevant percent encodings.', async(): Promise<void> => { it('encodes relevant characters.', async(): Promise<void> => {
await expect(extractor.handle({ url: '/a%20path%26/name', headers: { host: 'test.com' }} as any)) await expect(extractor.handle({ url: '/a%20path%26/name', headers: { host: 'test.com' }} as any))
.resolves.toEqual({ path: 'http://test.com/a%20path&/name' }); .resolves.toEqual({ path: 'http://test.com/a%20path%26/name' });
await expect(extractor.handle({ url: '/a path%26/name', headers: { host: 'test.com' }} as any))
.resolves.toEqual({ path: 'http://test.com/a%20path%26/name' });
await expect(extractor.handle({ url: '/path&%26/name', headers: { host: 'test.com' }} as any))
.resolves.toEqual({ path: 'http://test.com/path%26%26/name' });
}); });
}); });

View File

@ -1,6 +1,12 @@
import streamifyArray from 'streamify-array'; import streamifyArray from 'streamify-array';
import { UnsupportedHttpError } from '../../../src/util/errors/UnsupportedHttpError'; import {
import { ensureTrailingSlash, matchingMediaType, readableToString, toCanonicalUrl } from '../../../src/util/Util'; decodeUriPathComponents,
encodeUriPathComponents,
ensureTrailingSlash,
matchingMediaType,
readableToString,
toCanonicalUriPath,
} from '../../../src/util/Util';
describe('Util function', (): void => { describe('Util function', (): void => {
describe('ensureTrailingSlash', (): void => { describe('ensureTrailingSlash', (): void => {
@ -33,14 +39,17 @@ describe('Util function', (): void => {
}); });
}); });
describe('toCanonicalUrl', (): void => { describe('UriPath functions', (): void => {
it('makes sure only the necessary parts are encoded.', async(): Promise<void> => { it('makes sure only the necessary parts are encoded with toCanonicalUriPath.', async(): Promise<void> => {
expect(toCanonicalUrl('http://test.com/a%20path%26/name')) expect(toCanonicalUriPath('/a%20path&/name')).toEqual('/a%20path%26/name');
.toEqual('http://test.com/a%20path&/name');
}); });
it('errors on invalid URLs.', async(): Promise<void> => { it('decodes all parts of a path with decodeUriPathComponents.', async(): Promise<void> => {
expect((): any => toCanonicalUrl('notAnUrl')).toThrow(new UnsupportedHttpError('Invalid URL notAnUrl')); expect(decodeUriPathComponents('/a%20path&/name')).toEqual('/a path&/name');
});
it('encodes all parts of a path with encodeUriPathComponents.', async(): Promise<void> => {
expect(encodeUriPathComponents('/a%20path&/name')).toEqual('/a%2520path%26/name');
}); });
}); });
}); });