From 107f86a37c09821ecb6134b8c618c9112ab21858 Mon Sep 17 00:00:00 2001 From: fortress Date: Tue, 16 Jun 2026 06:14:42 -0400 Subject: [PATCH 1/3] fix: add blockedPatterns to TokenOwnershipValidator to prevent SSRF --- .../ownership/TokenOwnershipValidator.ts | 22 ++++++++++++++++++- src/util/FetchUtil.ts | 6 ++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/identity/ownership/TokenOwnershipValidator.ts b/src/identity/ownership/TokenOwnershipValidator.ts index db2dd1f552..f386accb2b 100644 --- a/src/identity/ownership/TokenOwnershipValidator.ts +++ b/src/identity/ownership/TokenOwnershipValidator.ts @@ -20,15 +20,22 @@ export class TokenOwnershipValidator extends OwnershipValidator { private readonly storage: ExpiringStorage; private readonly expiration: number; + private readonly blockedWebIdPatterns: RegExp[]; - public constructor(storage: ExpiringStorage, expiration = 30) { + public constructor(storage: ExpiringStorage, expiration = 30, blockedWebIdPatterns: string[] = [], ) { super(); this.storage = storage; // Convert minutes to milliseconds this.expiration = expiration * 60 * 1000; + // Convert strings to RegExp (same pattern as BaseRouterHandler) + this.blockedWebIdPatterns = blockedWebIdPatterns.map((p): RegExp => new RegExp(p, 'u')); } public async handle({ webId }: { webId: string }): Promise { + // Check operator-configured block list before generating or storing any token. + // This runs before any HTTP request is made, preventing SSRF via early exit. + this.assertWebIdAllowed(webId); + const key = this.getTokenKey(webId); let token = await this.storage.get(key); @@ -47,6 +54,19 @@ export class TokenOwnershipValidator extends OwnershipValidator { await this.storage.delete(key); } + /** + * Rejects the WebID if it matches any operator-configured pattern. + * Each pattern is a RegExp compiled from the string provided in the config. + */ + private assertWebIdAllowed(webId: string): void { + for (const pattern of this.blockedWebIdPatterns) { + if (pattern.test(webId)) { + this.logger.warn(`Blocked WebID URL matching pattern ${pattern.source}: ${webId}`); + throw new BadRequestHttpError('The provided WebID is not accepted by this server.'); + } + } + } + /** * Creates a key to use with the token storage. */ diff --git a/src/util/FetchUtil.ts b/src/util/FetchUtil.ts index 699069e260..00b9ba805f 100644 --- a/src/util/FetchUtil.ts +++ b/src/util/FetchUtil.ts @@ -25,8 +25,12 @@ export async function fetchDataset(url: string): Promise { const quadArray = await arrayifyStream(quadStream); return new BasicRepresentation(quadArray, { path: url }, INTERNAL_QUADS, false); } catch (error: unknown) { + // Log full detail server-side only. + // Generic client message prevents leaking network state (ECONNREFUSED, ETIMEDOUT, etc.) + // which would otherwise allow using this endpoint as an internal port scanner. + logger.warn(`Could not fetch dataset at ${url}: ${createErrorMessage(error)}`); throw new BadRequestHttpError( - `Could not parse resource at URL (${url})! ${createErrorMessage(error)}`, + `Could not retrieve or parse the WebID document at ${url}.`, { cause: error }, ); } From 3bb1a6b551cdcc67b864b3cf69bcb7637940f95d Mon Sep 17 00:00:00 2001 From: fortress Date: Tue, 16 Jun 2026 06:35:21 -0400 Subject: [PATCH 2/3] test: add tests to check SSRF --- .../ownership/TokenOwnershipValidator.test.ts | 56 +++++++++++++++++++ test/unit/util/FetchUtil.test.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts index 4c71a9baac..a99c7598d8 100644 --- a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts +++ b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts @@ -98,4 +98,60 @@ describe('A TokenOwnershipValidator', (): void => { // Second call will fail since it has the wrong verification triple await expect(validator.handle({ webId })).rejects.toThrow(tokenString); }); + + describe('with blockedWebIdPatterns configured', (): void => { + const blockedPatterns = [ + '^https?://169\\.254\\.', + '^https?://localhost', + '^https?://127\\.', + '^https?://10\\.', + '^https?://192\\.168\\.', + ]; + + beforeEach((): void => { + jest.clearAllMocks(); + validator = new TokenOwnershipValidator(storage, 30, blockedPatterns); + }); + + it('rejects a WebID matching a blocked pattern before generating a token.', async(): Promise => { + const blockedWebId = 'http://169.254.169.254/latest/meta-data/'; + await expect(validator.handle({ webId: blockedWebId })) + .rejects.toThrow('The provided WebID is not accepted by this server.'); + expect(storage.get).not.toHaveBeenCalled(); + expect(storage.set).not.toHaveBeenCalled(); + expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); + }); + + it('rejects a localhost WebID.', async(): Promise => { + await expect(validator.handle({ webId: 'http://localhost:6379/' })) + .rejects.toThrow('The provided WebID is not accepted by this server.'); + expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); + }); + + it('rejects a private IP range WebID.', async(): Promise => { + await expect(validator.handle({ webId: 'http://192.168.1.100/profile' })) + .rejects.toThrow('The provided WebID is not accepted by this server.'); + expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); + }); + + it('rejects a 10.x.x.x WebID.', async(): Promise => { + await expect(validator.handle({ webId: 'http://10.0.0.1/profile' })) + .rejects.toThrow('The provided WebID is not accepted by this server.'); + expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); + }); + + it('allows a public WebID that does not match any blocked pattern.', async(): Promise => { + mockDereference(tokenTriple); + // First call stores token, second call verifies + await expect(validator.handle({ webId })).rejects.toThrow(tokenString); + await expect(validator.handle({ webId })).resolves.toBeUndefined(); + }); + + it('has no blocked patterns by default (empty array).', async(): Promise => { + const defaultValidator = new TokenOwnershipValidator(storage); + mockDereference(tokenTriple); + await expect(defaultValidator.handle({ webId })).rejects.toThrow(tokenString); + await expect(defaultValidator.handle({ webId })).resolves.toBeUndefined(); + }); + }); }); diff --git a/test/unit/util/FetchUtil.test.ts b/test/unit/util/FetchUtil.test.ts index 7229d39215..d0cebe9425 100644 --- a/test/unit/util/FetchUtil.test.ts +++ b/test/unit/util/FetchUtil.test.ts @@ -42,7 +42,7 @@ describe('FetchUtil', (): void => { it('errors if the URL does not exist.', async(): Promise => { mockDereference(); - await expect(fetchDataset(url)).rejects.toThrow(`Could not parse resource at URL (${url})!`); + await expect(fetchDataset(url)).rejects.toThrow(`Could not retrieve or parse the WebID document at ${url}.`); expect(rdfDereferenceMock.dereference).toHaveBeenCalledWith(url); }); From 13e2fbfef369fa221e99c85c2bb127ef6111bf48 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 17 Jun 2026 09:32:09 +0200 Subject: [PATCH 3/3] refactor: Fix linting issues --- .../ownership/TokenOwnershipValidator.ts | 4 ++-- .../ownership/TokenOwnershipValidator.test.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/identity/ownership/TokenOwnershipValidator.ts b/src/identity/ownership/TokenOwnershipValidator.ts index f386accb2b..2ff9de0356 100644 --- a/src/identity/ownership/TokenOwnershipValidator.ts +++ b/src/identity/ownership/TokenOwnershipValidator.ts @@ -22,7 +22,7 @@ export class TokenOwnershipValidator extends OwnershipValidator { private readonly expiration: number; private readonly blockedWebIdPatterns: RegExp[]; - public constructor(storage: ExpiringStorage, expiration = 30, blockedWebIdPatterns: string[] = [], ) { + public constructor(storage: ExpiringStorage, expiration = 30, blockedWebIdPatterns: string[] = []) { super(); this.storage = storage; // Convert minutes to milliseconds @@ -35,7 +35,7 @@ export class TokenOwnershipValidator extends OwnershipValidator { // Check operator-configured block list before generating or storing any token. // This runs before any HTTP request is made, preventing SSRF via early exit. this.assertWebIdAllowed(webId); - + const key = this.getTokenKey(webId); let token = await this.storage.get(key); diff --git a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts index a99c7598d8..36b850f505 100644 --- a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts +++ b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts @@ -98,7 +98,7 @@ describe('A TokenOwnershipValidator', (): void => { // Second call will fail since it has the wrong verification triple await expect(validator.handle({ webId })).rejects.toThrow(tokenString); }); - + describe('with blockedWebIdPatterns configured', (): void => { const blockedPatterns = [ '^https?://169\\.254\\.', @@ -107,12 +107,12 @@ describe('A TokenOwnershipValidator', (): void => { '^https?://10\\.', '^https?://192\\.168\\.', ]; - + beforeEach((): void => { jest.clearAllMocks(); validator = new TokenOwnershipValidator(storage, 30, blockedPatterns); }); - + it('rejects a WebID matching a blocked pattern before generating a token.', async(): Promise => { const blockedWebId = 'http://169.254.169.254/latest/meta-data/'; await expect(validator.handle({ webId: blockedWebId })) @@ -121,37 +121,37 @@ describe('A TokenOwnershipValidator', (): void => { expect(storage.set).not.toHaveBeenCalled(); expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); }); - + it('rejects a localhost WebID.', async(): Promise => { await expect(validator.handle({ webId: 'http://localhost:6379/' })) .rejects.toThrow('The provided WebID is not accepted by this server.'); expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); }); - + it('rejects a private IP range WebID.', async(): Promise => { await expect(validator.handle({ webId: 'http://192.168.1.100/profile' })) .rejects.toThrow('The provided WebID is not accepted by this server.'); expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); }); - + it('rejects a 10.x.x.x WebID.', async(): Promise => { await expect(validator.handle({ webId: 'http://10.0.0.1/profile' })) .rejects.toThrow('The provided WebID is not accepted by this server.'); expect(rdfDereferenceMock.dereference).not.toHaveBeenCalled(); }); - + it('allows a public WebID that does not match any blocked pattern.', async(): Promise => { mockDereference(tokenTriple); // First call stores token, second call verifies await expect(validator.handle({ webId })).rejects.toThrow(tokenString); await expect(validator.handle({ webId })).resolves.toBeUndefined(); }); - + it('has no blocked patterns by default (empty array).', async(): Promise => { const defaultValidator = new TokenOwnershipValidator(storage); mockDereference(tokenTriple); await expect(defaultValidator.handle({ webId })).rejects.toThrow(tokenString); await expect(defaultValidator.handle({ webId })).resolves.toBeUndefined(); }); - }); + }); });