diff --git a/src/identity/ownership/TokenOwnershipValidator.ts b/src/identity/ownership/TokenOwnershipValidator.ts index db2dd1f552..2ff9de0356 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 }, ); } diff --git a/test/unit/identity/ownership/TokenOwnershipValidator.test.ts b/test/unit/identity/ownership/TokenOwnershipValidator.test.ts index 4c71a9baac..36b850f505 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); });