Skip to content
This repository has been archived by the owner on Jul 25, 2024. It is now read-only.

Commit

Permalink
Implement correct validation for DSNP URIs (#150)
Browse files Browse the repository at this point in the history
# Description
Previously, DTO validation for endpoints was requiring any `contentHash`
to be in hexadecimal format with a leading `0x`. According to the next
version of the DSNP spec, content hashes may be any valid `multiformat`
encoded hash value.

This PR implements the following:
- Custom decorator for DSNP content hash validation, which accepts the
standard base32 or base58 encodings as well as base16 (hexadecimal)
(with or without a leading '0x')
- Custom decorator for DSNP ID (MSA) validation
- Custom decorator to validate DSNP User URI
- Custom decorator to validate DSNP Content URI
- Swap out regex-based DTO validation in favor of new custom decorator
validations

Closes #149
  • Loading branch information
JoeCap08055 authored Jul 18, 2024
1 parent bb6a4f0 commit 1d60d76
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 34 deletions.
14 changes: 6 additions & 8 deletions libs/common/src/dtos/activity.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsLatitude,
IsLongitude,
IsNumber,
Expand All @@ -22,7 +23,8 @@ import {
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { DSNP_USER_URI_REGEX, DURATION_REGEX, ISO8601_REGEX } from './validation.dto';
import { DURATION_REGEX } from './validation.dto';
import { IsDsnpUserURI } from '../utils/dsnp-validation.decorator';

// eslint-disable-next-line no-shadow
export enum UnitTypeDto {
Expand Down Expand Up @@ -114,9 +116,7 @@ export class TagDto {
name?: string;

@ValidateIf((o) => o.type === TagTypeDto.Mention)
@MinLength(1)
@IsString()
@Matches(DSNP_USER_URI_REGEX)
@IsDsnpUserURI({ message: 'Invalid DSNP User URI' })
mentionedId?: string;
}

Expand Down Expand Up @@ -165,8 +165,7 @@ export class NoteActivityDto extends BaseActivityDto {
@IsString()
content: string;

@IsString()
@Matches(ISO8601_REGEX)
@IsISO8601({ strict: true, strictSeparator: true })
published: string;

@IsOptional()
Expand All @@ -189,7 +188,6 @@ export class ProfileActivityDto extends BaseActivityDto {
summary?: string;

@IsOptional()
@IsString()
@Matches(ISO8601_REGEX)
@IsISO8601({ strict: true, strictSeparator: true })
published?: string;
}
17 changes: 7 additions & 10 deletions libs/common/src/dtos/announcement.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
* File name should always end with `.dto.ts` for swagger metadata generator to get picked up
*/
// eslint-disable-next-line max-classes-per-file
import { IsEnum, IsHexadecimal, IsInt, IsNotEmpty, IsString, Matches, Max, MaxLength, Min, MinLength, ValidateNested } from 'class-validator';
import { IsEnum, IsInt, IsNotEmpty, IsString, Matches, Max, Min, MinLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { NoteActivityDto, ProfileActivityDto } from './activity.dto';
import { DSNP_CONTENT_HASH_REGEX, DSNP_CONTENT_URI_REGEX, DSNP_EMOJI_REGEX } from './validation.dto';
import { DSNP_EMOJI_REGEX } from './validation.dto';
import { IsDsnpContentHash, IsDsnpContentURI } from '../utils/dsnp-validation.decorator';

// eslint-disable-next-line no-shadow
export enum ModifiableAnnouncementTypeDto {
Expand All @@ -21,8 +22,7 @@ export class BroadcastDto {
}

export class ReplyDto {
@IsString()
@Matches(DSNP_CONTENT_URI_REGEX)
@IsDsnpContentURI({ message: 'Invalid DSNP Content URI' })
inReplyTo: string;

@IsNotEmpty()
Expand All @@ -32,19 +32,17 @@ export class ReplyDto {
}

export class TombstoneDto {
@IsString()
@IsDsnpContentHash({ message: 'Invalid DSNP content hash' })
@IsNotEmpty()
@Matches(DSNP_CONTENT_HASH_REGEX, { message: 'targetContentHash must be in hexadecimal format!' })
targetContentHash: string;

@IsEnum(ModifiableAnnouncementTypeDto)
targetAnnouncementType: ModifiableAnnouncementTypeDto;
}

export class UpdateDto {
@IsString()
@IsDsnpContentHash({ message: 'Invalid DSNP content hash' })
@IsNotEmpty()
@Matches(DSNP_CONTENT_HASH_REGEX, { message: 'targetContentHash must be in hexadecimal format!' })
targetContentHash: string;

@IsEnum(ModifiableAnnouncementTypeDto)
Expand All @@ -67,8 +65,7 @@ export class ReactionDto {
@Max(255)
apply: number;

@IsString()
@Matches(DSNP_CONTENT_URI_REGEX)
@IsDsnpContentURI({ message: 'Invalid DSNP Content URI' })
inReplyTo: string;
}

Expand Down
17 changes: 1 addition & 16 deletions libs/common/src/dtos/validation.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,7 @@ import { AnnouncementTypeDto } from './common.dto';
* - Z or hour minute offset
* - example: 1970-01-01T00:00:00+00:00
*/
export const ISO8601_REGEX = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,})?(Z|[+-][01][0-9]:[0-5][0-9])?$/;
/**
* DSNP content hash based on DSNP Spec
* example: 0x1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef
*/
export const DSNP_CONTENT_HASH_REGEX = /^0x[0-9a-f]+$/i;
/**
* DSNP user URI based on DSNP Spec
* example: dsnp://78187493520
*/
export const DSNP_USER_URI_REGEX = /^dsnp:\/\/[1-9][0-9]{0,19}$/i;
/**
* DSNP content URI based on DSNP Spec
* example: dsnp://78187493520/0x1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef
*/
export const DSNP_CONTENT_URI_REGEX = /^dsnp:\/\/[1-9][0-9]{0,19}\/0x[0-9a-f]+$/i;
export const ISO8601_REGEX = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(Z|[+-][01][0-9]:[0-5][0-9])?$/;
/**
* DSNP character ranges for valid emojis
*/
Expand Down
149 changes: 149 additions & 0 deletions libs/common/src/utils/dsnp-validation.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
import { base16 } from 'multiformats/bases/base16';
import { CID } from 'multiformats';

const MAX_U64_BIGINT = 18_446_744_073_709_551_615n;

function validateMsaIdString(msaId: string): boolean {
if (msaId.startsWith('0')) {
console.error('DSNP User ID may not contain a leading zero');
return false;
}

try {
const uid = BigInt(msaId);
if (uid > MAX_U64_BIGINT) {
throw new RangeError();
}
} catch (err) {
console.error('Invalid DSNP User ID in URI');
return false;
}

return true;
}

const hexRe = /^(?:0x)?(?<hexString>f[0-9a-f]+)$/i;
function validateContentHash(contentHash: string): boolean {
try {
const hexMatch = hexRe.exec(contentHash);
if (hexMatch && hexMatch?.groups) {
const { hexString } = hexMatch.groups;
const decoded = base16.decode(hexString.toLowerCase());
CID.decode(decoded);
} else {
const cid = CID.parse(contentHash);
console.log(cid.toString(base16.encoder));
}
} catch (err: any) {
console.error(`Invalid multiformat content hash: ${err.message}`);
return false;
}

return true;
}

export function IsDsnpUserURI(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isDsnpUserURI',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: unknown, _args: ValidationArguments) {
const re = /^(?<protocol>.*):\/\/(?<msaId>[\d]*)$/;

if (typeof value !== 'string') {
console.error('Invalid DSNP User URI');
return false;
}

const result = re.exec(value);
if (!result || !result?.groups) {
return false;
}

const { protocol, msaId } = result.groups;
if (protocol !== 'dsnp') {
console.error('DSNP URI protocol must be "dsnp"');
return false;
}

if (!validateMsaIdString(msaId)) {
return false;
}

return true;
},
},
});
};
}

export function IsDsnpContentURI(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isDsnpContentURI',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: unknown, _args: ValidationArguments) {
const re = /^(?<protocol>.*):\/\/(?<msaId>[\d]*)\/(?<contentHash>.*)$/;

if (typeof value !== 'string') {
console.error('Invalid DSNP Content URI');
return false;
}

const result = re.exec(value);
if (!result || !result?.groups) {
return false;
}

const { protocol, msaId, contentHash } = result.groups;
if (protocol !== 'dsnp') {
console.error('DSNP URI protocol must be "dsnp"');
return false;
}

if (!validateMsaIdString(msaId)) {
return false;
}

if (!validateContentHash(contentHash)) {
return false;
}

return true;
},
},
});
};
}

export function IsDsnpContentHash(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isDsnpContentHash',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: unknown, _args: ValidationArguments) {
if (typeof value !== 'string') {
console.error('Invalid DSNP Content Hash');
return false;
}

if (!validateContentHash(value)) {
return false;
}

return true;
},
},
});
};
}

0 comments on commit 1d60d76

Please sign in to comment.