Skip to content
58 changes: 11 additions & 47 deletions app/lib/credentialDisplay/shared/utils/alignment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Alignment } from '../../../../types/credential';
import { validateUrl, isUrlSuspicious } from '../../../urlUtils';

export type ValidAlignment = {
targetName: string;
Expand All @@ -7,49 +8,6 @@ export type ValidAlignment = {
isValidUrl?: boolean;
};

function normalizeUrl(raw: string): string {
const trimmed = String(raw).trim();
// If it already has a scheme (e.g., http:, https)
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
// Otherwise, assume https
return `https://${trimmed}`;
}

function isStrictHttpUrl(raw: string): string | null {
const candidate = String(raw).trim();

if (candidate.length === 0) return null;
if (/\s/.test(candidate)) return null;

const normalized = normalizeUrl(candidate);

// Reject strings that embed multiple schemes
const firstSchemeIdx = normalized.indexOf('://');
if (firstSchemeIdx === -1) return null;
if (normalized.indexOf('://', firstSchemeIdx + 3) !== -1) return null;

try {
const u = new URL(normalized);
// Only allow http(s)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
// Must have a hostname
if (!u.hostname) return null;
// Forbid credentials/userinfo
if (u.username || u.password) return null;
// Hostname must be reasonable (letters, numbers, dashes, dots) or be 'localhost' or an IPv4
const isHostname = /^[A-Za-z0-9.-]+$/.test(u.hostname);
const isLocalhost = u.hostname.toLowerCase() === 'localhost';
const isIPv4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(u.hostname);
if (!(isHostname || isLocalhost || isIPv4)) return null;
// If it's a standard hostname (not localhost/IP), require at least one dot
if (isHostname && !isLocalhost && !isIPv4 && !u.hostname.includes('.')) return null;

return normalized;
} catch {
return null;
}
}

export function getValidAlignments(alignments?: Alignment[]): ValidAlignment[] {
if (!alignments || !Array.isArray(alignments)) {
return [];
Expand All @@ -65,13 +23,19 @@ export function getValidAlignments(alignments?: Alignment[]): ValidAlignment[] {
targetName: alignment.targetName!,
targetDescription: alignment.targetDescription,
};

if (alignment.targetUrl) {
const normalizedUrl = isStrictHttpUrl(alignment.targetUrl);
const validation = validateUrl(alignment.targetUrl);

// Warn about suspicious URLs
if (validation?.valid && isUrlSuspicious(alignment.targetUrl)) {
console.warn(`Suspicious alignment URL: ${alignment.targetUrl}`);
}

result.targetUrl = alignment.targetUrl;
result.isValidUrl = !!normalizedUrl;
result.isValidUrl = validation?.valid ?? false;
}

return result;
});
}
21 changes: 21 additions & 0 deletions app/lib/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isChapiCredentialResponse, isChapiDidAuthRequest, isVerifiableCredentia
import { CredentialRecordRaw } from '../model';
import { NavigationUtil } from './navigationUtil';
import { DidAuthRequestParams, performDidAuthRequest } from './didAuthRequest';
import { validateUrl } from './urlUtils';

import { LinkConfig } from '../../app.config';

Expand All @@ -23,6 +24,23 @@ export const regexPattern = {
json: /^{.*}$/s,
};

/**
* URL validation for credential fetching
*/
function isValidCredentialUrl(text: string): boolean {
if (!regexPattern.url.test(text)) {
return false;
}

const validation = validateUrl(text);
if (!validation.valid) {
console.warn(`Invalid credential URL: ${text} - ${validation.error}`);
return false;
}

return true;
}

export function isDeepLink(text: string): boolean {
return text.startsWith(LinkConfig.schemes.universalAppLink) || !!LinkConfig.schemes.customProtocol.find((link) => text.startsWith(link));
}
Expand Down Expand Up @@ -118,6 +136,9 @@ async function credentialsFromJson(text: string): Promise<Credential[]> {
*/
export async function credentialsFrom(text: string): Promise<Credential[]> {
if (regexPattern.url.test(text)) {
if (!isValidCredentialUrl(text)) {
throw new Error('Invalid or potentially unsafe URL provided');
}
const response = await fetch(text);
text = await response.text().then((t) => t.trim());
}
Expand Down
7 changes: 4 additions & 3 deletions app/lib/exchanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Ed25519Signature2020 } from '@digitalcredentials/ed25519-signature-2020
import { securityLoader } from '@digitalcredentials/security-document-loader';
import { ObjectId } from 'bson';
import store from '../store';
import validator from 'validator';
import { CredentialRecord } from '../model';
import { navigationRef } from '../navigation/navigationRef';
import { clearSelectedExchangeCredentials, selectExchangeCredentials } from '../store/slices/credentialFoyer';
Expand All @@ -15,6 +14,7 @@ import { getGlobalModalBody } from './globalModalBody';
import { delay } from './time';
import { filterCredentialRecordsByType } from './credentialMatching';
import handleZcapRequest from './handleZcapRequest';
import { validateUrl } from './urlUtils';

const MAX_INTERACTIONS = 10;

Expand Down Expand Up @@ -198,8 +198,9 @@ export async function handleVcApiExchangeComplete ({
if (interactions === MAX_INTERACTIONS) {
throw new Error(`Request timed out after ${interactions} interactions`);
}
if (!validator.isURL(url + '')) {
throw new Error(`Received invalid interaction URL from issuer: ${url}`);
const urlValidation = validateUrl(url, { allowHttp: true });
if (!urlValidation.valid) {
throw new Error(`Received invalid interaction URL from issuer: ${url} - ${urlValidation.error}`);
}

// Start the exchange process - POST an empty {} to the exchange API url
Expand Down
38 changes: 38 additions & 0 deletions app/lib/urlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import validator from 'validator';

// function for all URL validation needs
export function validateUrl(url: string, options: { allowHttp?: boolean } = {}) {
const trimmed = url.trim();
if (!trimmed) return { valid: false, error: 'Empty URL' };

const normalized = /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
const protocols = options.allowHttp ? ['http', 'https'] : ['https'];

return validator.isURL(normalized, { protocols, require_protocol: true })
? { valid: true, url: normalized }
: { valid: false, error: 'Invalid URL' };
}

// Basic security check
export function isUrlSuspicious(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);

// Common shorteners
const shorteners = ['bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly'];
if (shorteners.includes(hostname)) return true;

// Suspicious TLDs
if (/\.(tk|ml|ga|cf|click)$/.test(hostname)) return true;

// Phishing keywords
if (/\b(verify|urgent|suspend|update|confirm|secure)\b/i.test(pathname)) return true;

// IP addresses
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return true;

return false;
} catch {
return true;
}
}
58 changes: 58 additions & 0 deletions test/urlValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { validateUrl, isUrlSuspicious } from '../app/lib/urlUtils';

describe('validateUrl', () => {
it('should validate HTTPS URLs', () => {
const result = validateUrl('https://example.com');
expect(result.valid).toBe(true);
expect(result.url).toBe('https://example.com');
});

it('should normalize URLs without protocol', () => {
const result = validateUrl('example.com');
expect(result.valid).toBe(true);
expect(result.url).toBe('https://example.com');
});

it('should reject HTTP URLs by default', () => {
const result = validateUrl('http://example.com');
expect(result.valid).toBe(false);
});

it('should allow HTTP URLs when configured', () => {
const result = validateUrl('http://example.com', { allowHttp: true });
expect(result.valid).toBe(true);
});

it('should reject empty URLs', () => {
const result = validateUrl('');
expect(result.valid).toBe(false);
expect(result.error).toBe('Empty URL');
});

it('should reject invalid URLs', () => {
const result = validateUrl('not-a-url');
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid URL');
});
});

describe('isUrlSuspicious', () => {
it('should detect URL shorteners', () => {
expect(isUrlSuspicious('https://bit.ly/test')).toBe(true);
expect(isUrlSuspicious('https://tinyurl.com/test')).toBe(true);
});

it('should detect phishing keywords', () => {
expect(isUrlSuspicious('https://example.com/verify-account')).toBe(true);
expect(isUrlSuspicious('https://example.com/urgent-update')).toBe(true);
});

it('should mark safe URLs as safe', () => {
expect(isUrlSuspicious('https://example.com')).toBe(false);
expect(isUrlSuspicious('https://github.com/repo')).toBe(false);
});

it('should handle malformed URLs', () => {
expect(isUrlSuspicious('not-a-url')).toBe(true);
});
});