mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
parent
3685b7c659
commit
48efc6fae1
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
10
config/ldp/metadata-parser/parsers/plain-json-ld-filter.json
Normal file
10
config/ldp/metadata-parser/parsers/plain-json-ld-filter.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
45
src/http/input/metadata/PlainJsonLdFilter.ts
Normal file
45
src/http/input/metadata/PlainJsonLdFilter.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -108,6 +108,16 @@ export interface ContentType {
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
||||
|
63
test/unit/http/input/metadata/PlainJsonLdFilter.test.ts
Normal file
63
test/unit/http/input/metadata/PlainJsonLdFilter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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<void> => {
|
||||
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 = [ '<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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user