Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/identity/ownership/TokenOwnershipValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,22 @@ export class TokenOwnershipValidator extends OwnershipValidator {

private readonly storage: ExpiringStorage<string, string>;
private readonly expiration: number;
private readonly blockedWebIdPatterns: RegExp[];

public constructor(storage: ExpiringStorage<string, string>, expiration = 30) {
public constructor(storage: ExpiringStorage<string, string>, 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<void> {
// 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);

Expand All @@ -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.
*/
Expand Down
6 changes: 5 additions & 1 deletion src/util/FetchUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ export async function fetchDataset(url: string): Promise<Representation> {
const quadArray = await arrayifyStream<Quad>(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 },
);
}
Expand Down
56 changes: 56 additions & 0 deletions test/unit/identity/ownership/TokenOwnershipValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
const defaultValidator = new TokenOwnershipValidator(storage);
mockDereference(tokenTriple);
await expect(defaultValidator.handle({ webId })).rejects.toThrow(tokenString);
await expect(defaultValidator.handle({ webId })).resolves.toBeUndefined();
});
});
});
2 changes: 1 addition & 1 deletion test/unit/util/FetchUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('FetchUtil', (): void => {

it('errors if the URL does not exist.', async(): Promise<void> => {
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);
});

Expand Down
Loading