feat: Create MetadataParser that detects JSON with Context link and throws an error

* feat: add PlainJsonLdFilter to reject JSON with context link

* refactor: abstract parseLinkHeader into HeaderUtils

* docs: typo in comment field

Co-authored-by: Ruben Verborgh <ruben@verborgh.org>

* refactor: Replace BadRequestHttpError with NotImplementedError

Co-authored-by: Ruben Verborgh <ruben@verborgh.org>

* refactor: incorporate requested changes

* refactor: requested changes incorporated

* refactor: remove obsolete code lines

Co-authored-by: Ruben Verborgh <ruben@verborgh.org>
This commit is contained in:
Thomas Dupont 2022-04-01 14:25:09 +02:00 committed by GitHub
parent 3685b7c659
commit 48efc6fae1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 323 additions and 28 deletions

View File

@ -1,10 +1,11 @@
{ {
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"import": [ "import": [
"files-scs:config/ldp/metadata-parser/parsers/content-type.json",
"files-scs:config/ldp/metadata-parser/parsers/content-length.json", "files-scs:config/ldp/metadata-parser/parsers/content-length.json",
"files-scs:config/ldp/metadata-parser/parsers/slug.json", "files-scs:config/ldp/metadata-parser/parsers/content-type.json",
"files-scs:config/ldp/metadata-parser/parsers/link.json" "files-scs:config/ldp/metadata-parser/parsers/link.json",
"files-scs:config/ldp/metadata-parser/parsers/plain-json-ld-filter.json",
"files-scs:config/ldp/metadata-parser/parsers/slug.json"
], ],
"@graph": [ "@graph": [
{ {
@ -12,10 +13,11 @@
"@id": "urn:solid-server:default:MetadataParser", "@id": "urn:solid-server:default:MetadataParser",
"@type": "ParallelHandler", "@type": "ParallelHandler",
"handlers": [ "handlers": [
{ "@id": "urn:solid-server:default:ContentTypeParser" },
{ "@id": "urn:solid-server:default:ContentLengthParser" }, { "@id": "urn:solid-server:default:ContentLengthParser" },
{ "@id": "urn:solid-server:default:SlugParser" }, { "@id": "urn:solid-server:default:ContentTypeParser" },
{ "@id": "urn:solid-server:default:LinkRelParser" } { "@id": "urn:solid-server:default:LinkRelParser" },
{ "@id": "urn:solid-server:default:PlainJsonLdFilter" },
{ "@id": "urn:solid-server:default:SlugParser" }
] ]
} }
] ]

View File

@ -0,0 +1,10 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Checks for JSON-LD posted with plain application/json as content type and errors if so.",
"@id": "urn:solid-server:default:PlainJsonLdFilter",
"@type": "PlainJsonLdFilter"
}
]
}

View File

@ -2,7 +2,7 @@ import type { NamedNode } from '@rdfjs/types';
import { DataFactory } from 'n3'; import { DataFactory } from 'n3';
import { getLoggerFor } from '../../../logging/LogUtil'; import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpRequest } from '../../../server/HttpRequest'; import type { HttpRequest } from '../../../server/HttpRequest';
import { parseParameters, splitAndClean, transformQuotedStrings } from '../../../util/HeaderUtil'; import { parseLinkHeader } from '../../../util/HeaderUtil';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataParser } from './MetadataParser'; import { MetadataParser } from './MetadataParser';
import namedNode = DataFactory.namedNode; import namedNode = DataFactory.namedNode;
@ -23,25 +23,9 @@ export class LinkRelParser extends MetadataParser {
} }
public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise<void> { public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise<void> {
const link = input.request.headers.link ?? []; for (const { target, parameters } of parseLinkHeader(input.request.headers.link)) {
const entries: string[] = Array.isArray(link) ? link : [ link ]; if (this.linkRelMap[parameters.rel]) {
for (const entry of entries) { input.metadata.add(this.linkRelMap[parameters.rel], namedNode(target));
this.parseLink(entry, input.metadata);
}
}
protected parseLink(linkEntry: string, metadata: RepresentationMetadata): void {
const { result, replacements } = transformQuotedStrings(linkEntry);
for (const part of splitAndClean(result)) {
const [ link, ...parameters ] = part.split(/\s*;\s*/u);
if (/^[^<]|[^>]$/u.test(link)) {
this.logger.warn(`Invalid link header ${part}.`);
continue;
}
for (const { name, value } of parseParameters(parameters, replacements)) {
if (name === 'rel' && this.linkRelMap[value]) {
metadata.add(this.linkRelMap[value], namedNode(link.slice(1, -1)));
}
} }
} }
} }

View File

@ -0,0 +1,45 @@
import { getLoggerFor } from '../../../logging/LogUtil';
import type { HttpRequest } from '../../../server/HttpRequest';
import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError';
import { parseContentType, parseLinkHeader } from '../../../util/HeaderUtil';
import { JSON_LD } from '../../../util/Vocabularies';
import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
import { MetadataParser } from './MetadataParser';
/**
* Filter that errors on JSON-LD with a plain application/json content-type.
* This will not store metadata, only throw errors if necessary.
*/
export class PlainJsonLdFilter extends MetadataParser {
protected readonly logger = getLoggerFor(this);
public constructor() {
super();
}
public async handle(input: {
request: HttpRequest;
metadata: RepresentationMetadata;
}): Promise<void> {
const contentTypeHeader = input.request.headers['content-type'];
if (!contentTypeHeader) {
return;
}
const { value: contentType } = parseContentType(contentTypeHeader);
// Throw error on content-type application/json AND a link header that refers to a JSON-LD context.
if (
contentType === 'application/json' &&
this.linkHasContextRelation(input.request.headers.link)
) {
throw new NotImplementedHttpError(
'JSON-LD is only supported with the application/ld+json content type.',
);
}
}
private linkHasContextRelation(link: string | string[] = []): boolean {
return parseLinkHeader(link).some(
({ parameters }): boolean => parameters.rel === JSON_LD.context,
);
}
}

View File

@ -63,6 +63,7 @@ export * from './http/input/metadata/ContentLengthParser';
export * from './http/input/metadata/ContentTypeParser'; export * from './http/input/metadata/ContentTypeParser';
export * from './http/input/metadata/LinkRelParser'; export * from './http/input/metadata/LinkRelParser';
export * from './http/input/metadata/MetadataParser'; export * from './http/input/metadata/MetadataParser';
export * from './http/input/metadata/PlainJsonLdFilter';
export * from './http/input/metadata/SlugParser'; export * from './http/input/metadata/SlugParser';
// HTTP/Input/Preferences // HTTP/Input/Preferences

View File

@ -108,6 +108,16 @@ export interface ContentType {
parameters: Record<string, string>; parameters: Record<string, string>;
} }
export interface LinkEntryParameters extends Record<string, string> {
/** Required rel properties of Link entry */
rel: string;
}
export interface LinkEntry {
target: string;
parameters: LinkEntryParameters;
}
// REUSED REGEXES // REUSED REGEXES
const tchar = /[a-zA-Z0-9!#$%&'*+-.^_`|~]/u; const tchar = /[a-zA-Z0-9!#$%&'*+-.^_`|~]/u;
const token = new RegExp(`^${tchar.source}+$`, 'u'); const token = new RegExp(`^${tchar.source}+$`, 'u');
@ -495,3 +505,45 @@ export function parseForwarded(headers: IncomingHttpHeaders): Forwarded {
} }
return forwarded; return forwarded;
} }
/**
* Parses the link header(s) and returns an array of LinkEntry objects.
* @param link - A single link header or an array of link headers
* @returns A LinkEntry array, LinkEntry contains a link and a params Record&lt;string,string&gt;
*/
export function parseLinkHeader(link: string | string[] = []): LinkEntry[] {
const linkHeaders = Array.isArray(link) ? link : [ link ];
const links: LinkEntry[] = [];
for (const entry of linkHeaders) {
const { result, replacements } = transformQuotedStrings(entry);
for (const part of splitAndClean(result)) {
const [ target, ...parameters ] = part.split(/\s*;\s*/u);
if (/^[^<]|[^>]$/u.test(target)) {
logger.warn(`Invalid link header ${part}.`);
continue;
}
// RFC 8288 - Web Linking (https://datatracker.ietf.org/doc/html/rfc8288)
//
// The rel parameter MUST be
// present but MUST NOT appear more than once in a given link-value;
// occurrences after the first MUST be ignored by parsers.
//
const params: any = {};
for (const { name, value } of parseParameters(parameters, replacements)) {
if (name === 'rel' && 'rel' in params) {
continue;
}
params[name] = value;
}
if (!('rel' in params)) {
logger.warn(`Invalid link header ${part} contains no 'rel' parameter.`);
continue;
}
links.push({ target: target.slice(1, -1), parameters: params });
}
}
return links;
}

View File

@ -96,6 +96,10 @@ export const HTTP = createUriAndTermNamespace('http://www.w3.org/2011/http#',
export const IANA = createUriAndTermNamespace('http://www.w3.org/ns/iana/media-types/'); export const IANA = createUriAndTermNamespace('http://www.w3.org/ns/iana/media-types/');
export const JSON_LD = createUriAndTermNamespace('http://www.w3.org/ns/json-ld#',
'context',
);
export const LDP = createUriAndTermNamespace('http://www.w3.org/ns/ldp#', export const LDP = createUriAndTermNamespace('http://www.w3.org/ns/ldp#',
'contains', 'contains',

View File

@ -0,0 +1,63 @@
import { NotImplementedHttpError } from '../../../../../src';
import { PlainJsonLdFilter } from '../../../../../src/http/input/metadata/PlainJsonLdFilter';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
describe('A PlainJsonLdFilter', (): void => {
const parser = new PlainJsonLdFilter();
let request: HttpRequest;
let metadata: RepresentationMetadata;
beforeEach(async(): Promise<void> => {
request = { headers: {}} as HttpRequest;
metadata = new RepresentationMetadata();
});
it('does nothing if there are no type headers.', async(): Promise<void> => {
await expect(parser.handle({ request, metadata })).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
it('does allow content-type application/json on its own.', async(): Promise<void> => {
request.headers['content-type'] = 'application/json';
await expect(parser.handle({ request, metadata })).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
it('does allow a correct content-type and link headers combination.', async(): Promise<void> => {
request.headers['content-type'] = 'application/json+ld';
request.headers.link = '<https://json-ld.org/contexts/person.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"';
await expect(parser.handle({ request, metadata })).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
it('throws error when content-type and link header are in conflict.', async(): Promise<void> => {
request.headers['content-type'] = 'application/json';
request.headers.link = '<https://json-ld.org/contexts/person.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"';
await expect(parser.handle({ request, metadata })).rejects.toThrow(NotImplementedHttpError);
expect(metadata.quads()).toHaveLength(0);
});
it('throws error when at least 1 content-type and link header are in conflict.', async(): Promise<void> => {
request.headers['content-type'] = 'application/json';
request.headers.link = [
'<http://test.com/type>; rel="type"',
'<https://json-ld.org/contexts/person.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"',
];
await expect(parser.handle({ request, metadata })).rejects.toThrow(NotImplementedHttpError);
expect(metadata.quads()).toHaveLength(0);
});
it('ignores invalid link headers.', async(): Promise<void> => {
request.headers['content-type'] = 'application/json';
request.headers.link = 'http://test.com/type;rel="type"';
await expect(parser.handle({ request, metadata })).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
it('ignores empty content-type headers.', async(): Promise<void> => {
request.headers.link = '<http://test.com/type>;rel="type"';
await expect(parser.handle({ request, metadata })).resolves.toBeUndefined();
expect(metadata.quads()).toHaveLength(0);
});
});

View File

@ -9,6 +9,7 @@ import {
parseAcceptLanguage, parseAcceptLanguage,
parseContentType, parseContentType,
parseForwarded, parseForwarded,
parseLinkHeader,
} from '../../../src/util/HeaderUtil'; } from '../../../src/util/HeaderUtil';
describe('HeaderUtil', (): void => { describe('HeaderUtil', (): void => {
@ -43,9 +44,11 @@ describe('HeaderUtil', (): void => {
it('parses Accept headers with double quoted values.', async(): Promise<void> => { it('parses Accept headers with double quoted values.', async(): Promise<void> => {
expect(parseAccept('audio/basic; param1="val" ; q=0.5 ;param2="\\\\\\"valid"')).toEqual([ expect(parseAccept('audio/basic; param1="val" ; q=0.5 ;param2="\\\\\\"valid"')).toEqual([
{ range: 'audio/basic', {
range: 'audio/basic',
weight: 0.5, weight: 0.5,
parameters: { mediaType: { param1: 'val' }, extension: { param2: '\\\\\\"valid' }}}, parameters: { mediaType: { param1: 'val' }, extension: { param2: '\\\\\\"valid' }},
},
]); ]);
}); });
@ -285,4 +288,135 @@ describe('HeaderUtil', (): void => {
}); });
}); });
}); });
describe('#parseLinkHeader', (): void => {
it('handles an empty set of headers.', (): void => {
expect(parseLinkHeader([])).toEqual([]);
});
it('handles empty string values.', (): void => {
expect(parseLinkHeader([ '' ])).toEqual([]);
});
it('parses a Link header value as array.', (): void => {
const link = [ '<http://test.com>; rel="myRel"; test="value1"' ];
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test.com',
parameters: {
rel: 'myRel',
test: 'value1',
},
},
]);
});
it('parses a Link header value as string.', (): void => {
const link = '<http://test.com>; rel="myRel"; test="value1"';
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test.com',
parameters: {
rel: 'myRel',
test: 'value1',
},
},
]);
});
it('parses multiple Link header values delimited by a comma.', (): void => {
const link = [ `<http://test.com>; rel="myRel"; test="value1",
<http://test2.com>; rel="myRel2"; test="value2"` ];
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test.com',
parameters: {
rel: 'myRel',
test: 'value1',
},
},
{
target: 'http://test2.com',
parameters: {
rel: 'myRel2',
test: 'value2',
},
},
]);
});
it('parses multiple Link header values as array elements.', (): void => {
const link = [
'<http://test.com>; rel="myRel"; test="value1"',
'<http://test2.com>; rel="myRel2"; test="value2"',
];
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test.com',
parameters: {
rel: 'myRel',
test: 'value1',
},
},
{
target: 'http://test2.com',
parameters: {
rel: 'myRel2',
test: 'value2',
},
},
]);
});
it('ignores invalid syntax links.', (): void => {
const link = [
'http://test.com; rel="myRel"; test="value1"',
'<http://test2.com>; rel="myRel2"; test="value2"',
];
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test2.com',
parameters: {
rel: 'myRel2',
test: 'value2',
},
},
]);
});
it('ignores invalid links (no rel parameter).', (): void => {
const link = [
'<http://test.com>; att="myAtt"; test="value1"',
'<http://test2.com>; rel="myRel2"; test="value2"',
];
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test2.com',
parameters: {
rel: 'myRel2',
test: 'value2',
},
},
]);
});
it('ignores extra rel parameters.', (): void => {
const link = [
'<http://test.com>; rel="myRel1"; rel="myRel2"; test="value1"',
];
expect(parseLinkHeader(link)).toEqual([
{
target: 'http://test.com',
parameters: {
rel: 'myRel1',
test: 'value1',
},
},
]);
});
it('works with an empty argument.', (): void => {
expect(parseLinkHeader()).toEqual([]);
});
});
}); });