diff --git a/config/ldp/metadata-parser/default.json b/config/ldp/metadata-parser/default.json index e886b3b90..64a3ff7d0 100644 --- a/config/ldp/metadata-parser/default.json +++ b/config/ldp/metadata-parser/default.json @@ -1,10 +1,11 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^3.0.0/components/context.jsonld", "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/slug.json", - "files-scs:config/ldp/metadata-parser/parsers/link.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/plain-json-ld-filter.json", + "files-scs:config/ldp/metadata-parser/parsers/slug.json" ], "@graph": [ { @@ -12,10 +13,11 @@ "@id": "urn:solid-server:default:MetadataParser", "@type": "ParallelHandler", "handlers": [ - { "@id": "urn:solid-server:default:ContentTypeParser" }, { "@id": "urn:solid-server:default:ContentLengthParser" }, - { "@id": "urn:solid-server:default:SlugParser" }, - { "@id": "urn:solid-server:default:LinkRelParser" } + { "@id": "urn:solid-server:default:ContentTypeParser" }, + { "@id": "urn:solid-server:default:LinkRelParser" }, + { "@id": "urn:solid-server:default:PlainJsonLdFilter" }, + { "@id": "urn:solid-server:default:SlugParser" } ] } ] diff --git a/config/ldp/metadata-parser/parsers/plain-json-ld-filter.json b/config/ldp/metadata-parser/parsers/plain-json-ld-filter.json new file mode 100644 index 000000000..334728e5b --- /dev/null +++ b/config/ldp/metadata-parser/parsers/plain-json-ld-filter.json @@ -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" + } + ] +} diff --git a/src/http/input/metadata/LinkRelParser.ts b/src/http/input/metadata/LinkRelParser.ts index c2b6bf397..a5ed70190 100644 --- a/src/http/input/metadata/LinkRelParser.ts +++ b/src/http/input/metadata/LinkRelParser.ts @@ -2,7 +2,7 @@ import type { NamedNode } from '@rdfjs/types'; import { DataFactory } from 'n3'; import { getLoggerFor } from '../../../logging/LogUtil'; 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 { MetadataParser } from './MetadataParser'; import namedNode = DataFactory.namedNode; @@ -23,25 +23,9 @@ export class LinkRelParser extends MetadataParser { } public async handle(input: { request: HttpRequest; metadata: RepresentationMetadata }): Promise { - const link = input.request.headers.link ?? []; - const entries: string[] = Array.isArray(link) ? link : [ link ]; - for (const entry of entries) { - 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))); - } + for (const { target, parameters } of parseLinkHeader(input.request.headers.link)) { + if (this.linkRelMap[parameters.rel]) { + input.metadata.add(this.linkRelMap[parameters.rel], namedNode(target)); } } } diff --git a/src/http/input/metadata/PlainJsonLdFilter.ts b/src/http/input/metadata/PlainJsonLdFilter.ts new file mode 100644 index 000000000..344bf9700 --- /dev/null +++ b/src/http/input/metadata/PlainJsonLdFilter.ts @@ -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 { + 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, + ); + } +} diff --git a/src/index.ts b/src/index.ts index e175a0b60..e785ed0c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,7 @@ export * from './http/input/metadata/ContentLengthParser'; export * from './http/input/metadata/ContentTypeParser'; export * from './http/input/metadata/LinkRelParser'; export * from './http/input/metadata/MetadataParser'; +export * from './http/input/metadata/PlainJsonLdFilter'; export * from './http/input/metadata/SlugParser'; // HTTP/Input/Preferences diff --git a/src/util/HeaderUtil.ts b/src/util/HeaderUtil.ts index be6269350..4fa3a5b78 100644 --- a/src/util/HeaderUtil.ts +++ b/src/util/HeaderUtil.ts @@ -108,6 +108,16 @@ export interface ContentType { parameters: Record; } +export interface LinkEntryParameters extends Record { + /** Required rel properties of Link entry */ + rel: string; +} + +export interface LinkEntry { + target: string; + parameters: LinkEntryParameters; +} + // REUSED REGEXES const tchar = /[a-zA-Z0-9!#$%&'*+-.^_`|~]/u; const token = new RegExp(`^${tchar.source}+$`, 'u'); @@ -495,3 +505,45 @@ export function parseForwarded(headers: IncomingHttpHeaders): 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<string,string> + */ +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; +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index c7c6d1f43..2126e9a6f 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -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 JSON_LD = createUriAndTermNamespace('http://www.w3.org/ns/json-ld#', + 'context', +); + export const LDP = createUriAndTermNamespace('http://www.w3.org/ns/ldp#', 'contains', diff --git a/test/unit/http/input/metadata/PlainJsonLdFilter.test.ts b/test/unit/http/input/metadata/PlainJsonLdFilter.test.ts new file mode 100644 index 000000000..54c967196 --- /dev/null +++ b/test/unit/http/input/metadata/PlainJsonLdFilter.test.ts @@ -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 => { + request = { headers: {}} as HttpRequest; + metadata = new RepresentationMetadata(); + }); + + it('does nothing if there are no type headers.', async(): Promise => { + 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 => { + 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 => { + request.headers['content-type'] = 'application/json+ld'; + request.headers.link = '; 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 => { + request.headers['content-type'] = 'application/json'; + request.headers.link = '; 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 => { + request.headers['content-type'] = 'application/json'; + request.headers.link = [ + '; rel="type"', + '; 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 => { + 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 => { + request.headers.link = ';rel="type"'; + await expect(parser.handle({ request, metadata })).resolves.toBeUndefined(); + expect(metadata.quads()).toHaveLength(0); + }); +}); diff --git a/test/unit/util/HeaderUtil.test.ts b/test/unit/util/HeaderUtil.test.ts index 7c9368927..ee44060c0 100644 --- a/test/unit/util/HeaderUtil.test.ts +++ b/test/unit/util/HeaderUtil.test.ts @@ -9,6 +9,7 @@ import { parseAcceptLanguage, parseContentType, parseForwarded, + parseLinkHeader, } from '../../../src/util/HeaderUtil'; describe('HeaderUtil', (): void => { @@ -43,9 +44,11 @@ describe('HeaderUtil', (): void => { it('parses Accept headers with double quoted values.', async(): Promise => { expect(parseAccept('audio/basic; param1="val" ; q=0.5 ;param2="\\\\\\"valid"')).toEqual([ - { range: 'audio/basic', + { + range: 'audio/basic', 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 = [ '; 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 = '; 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 = [ `; rel="myRel"; test="value1", + ; 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 = [ + '; rel="myRel"; test="value1"', + '; 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"', + '; 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 = [ + '; att="myAtt"; test="value1"', + '; 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 = [ + '; 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([]); + }); + }); });