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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
shamefully-hoist=true
13 changes: 6 additions & 7 deletions src/deep-link/deep-link.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Controller, Get, Param, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
import { DeepLinkService } from './deep-link.service';

@Controller()
export class DeepLinkController {
constructor(private readonly deepLinkService: DeepLinkService) {}

@Get('.well-known/apple-app-site-association')
getAppleAASA(@Res() res: Response) {
const aasa = {
Expand Down Expand Up @@ -43,13 +46,9 @@ export class DeepLinkController {
@Res() res: Response,
) {
const isMobile = /Mobile|Android|iPhone|iPod|iPad/i.test(userAgent || '');
const type = isMobile ? 'app' : 'web';
const location = this.deepLinkService.buildDeepLink(type, 'course', id);

if (isMobile) {
// Redirect to custom URL scheme
return res.redirect(`teachlink://course/${id}`);
}

// Redirect to web URL
return res.redirect(`/course/${id}`);
return res.redirect(location);
}
}
2 changes: 2 additions & 0 deletions src/deep-link/deep-link.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { DeepLinkController } from './deep-link.controller';
import { DeepLinkService } from './deep-link.service';

@Module({
controllers: [DeepLinkController],
providers: [DeepLinkService],
})
export class DeepLinkModule {}
125 changes: 125 additions & 0 deletions src/deep-link/deep-link.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { DeepLinkService } from './deep-link.service';

describe('DeepLinkService', () => {
let service: DeepLinkService;

beforeEach(() => {
service = new DeepLinkService();
});

describe('validateRoute', () => {
it('should return true for allowlisted routes', () => {
expect(service.validateRoute('course')).toBe(true);
expect(service.validateRoute('/course')).toBe(true);
});

it('should return false for non-allowlisted routes', () => {
expect(service.validateRoute('admin')).toBe(false);
expect(service.validateRoute('/admin')).toBe(false);
expect(service.validateRoute('settings')).toBe(false);
expect(service.validateRoute('')).toBe(false);
});
});

describe('validateParam', () => {
it('should accept valid alphanumeric params', () => {
expect(service.validateParam('123')).toBe('123');
expect(service.validateParam('abc')).toBe('abc');
expect(service.validateParam('ABC')).toBe('ABC');
expect(service.validateParam('course-123')).toBe('course-123');
expect(service.validateParam('course_456')).toBe('course_456');
});

it('should trim whitespace from params', () => {
expect(service.validateParam(' 123 ')).toBe('123');
});

it('should reject empty params', () => {
expect(() => service.validateParam('')).toThrow('Invalid parameter value');
expect(() => service.validateParam(' ')).toThrow('Parameter value cannot be empty');
});

it('should reject absolute URLs', () => {
expect(() => service.validateParam('http://evil.com')).toThrow('Absolute URLs are not allowed');
expect(() => service.validateParam('https://evil.com')).toThrow('Absolute URLs are not allowed');
expect(() => service.validateParam('ftp://evil.com')).toThrow('Absolute URLs are not allowed');
expect(() => service.validateParam('//evil.com')).toThrow('Absolute URLs are not allowed');
});

it('should reject external URL schemes', () => {
expect(() => service.validateParam('javascript:alert(1)')).toThrow('External URL schemes are not allowed');
expect(() => service.validateParam('data:text/html,<script>alert(1)</script>')).toThrow('External URL schemes are not allowed');
expect(() => service.validateParam('vbscript:msgbox(1)')).toThrow('External URL schemes are not allowed');
});

it('should reject path traversal attempts', () => {
expect(() => service.validateParam('../secret')).toThrow('Path traversal is not allowed');
expect(() => service.validateParam('..\\secret')).toThrow('Path traversal is not allowed');
expect(() => service.validateParam('../../etc/passwd')).toThrow('Path traversal is not allowed');
expect(() => service.validateParam('foo/../bar')).toThrow('Path traversal is not allowed');
});

it('should reject injection characters', () => {
expect(() => service.validateParam('<script>')).toThrow('Invalid characters in parameter');
expect(() => service.validateParam('>')).toThrow('Invalid characters in parameter');
expect(() => service.validateParam('"')).toThrow('Invalid characters in parameter');
expect(() => service.validateParam("'")).toThrow('Invalid characters in parameter');
expect(() => service.validateParam('`')).toThrow('Invalid characters in parameter');
expect(() => service.validateParam('{')).toThrow('Invalid characters in parameter');
expect(() => service.validateParam('}')).toThrow('Invalid characters in parameter');
expect(() => service.validateParam('\\')).toThrow('Invalid characters in parameter');
});

it('should reject invalid characters', () => {
expect(() => service.validateParam('hello world')).toThrow('Parameter contains invalid characters');
expect(() => service.validateParam('param@test')).toThrow('Parameter contains invalid characters');
expect(() => service.validateParam('param#test')).toThrow('Parameter contains invalid characters');
expect(() => service.validateParam('param?test')).toThrow('Parameter contains invalid characters');
});
});

describe('buildDeepLink', () => {
it('should build web deep link', () => {
const link = service.buildDeepLink('web', 'course', '123');
expect(link).toBe('/course/123');
});

it('should build app deep link', () => {
const link = service.buildDeepLink('app', 'course', '456');
expect(link).toBe('teachlink://course/456');
});

it('should pass valid params through unchanged', () => {
const link = service.buildDeepLink('web', 'course', 'abc-123_456');
expect(link).toBe('/course/abc-123_456');
});

it('should reject non-allowlisted routes', () => {
expect(() => service.buildDeepLink('web', 'admin', '123')).toThrow("Route 'admin' is not allowlisted");
});

it('should reject invalid params', () => {
expect(() => service.buildDeepLink('web', 'course', 'http://evil.com')).toThrow('Absolute URLs are not allowed');
expect(() => service.buildDeepLink('app', 'course', '../secret')).toThrow('Path traversal is not allowed');
});
});

describe('signLink and verifyLink', () => {
it('should sign and verify a link', () => {
const link = '/course/123';
const signed = service.signLink(link);
expect(signed).toMatch(/^\/course\/123\?sig=[a-f0-9]+$/);
expect(service.verifyLink(signed)).toBe(true);
});

it('should reject tampered signatures', () => {
const link = service.signLink('/course/123');
const tampered = link.replace('123', '999');
expect(service.verifyLink(tampered)).toBe(false);
});

it('should reject links without signature', () => {
expect(service.verifyLink('/course/123')).toBe(false);
});
});
});
102 changes: 102 additions & 0 deletions src/deep-link/deep-link.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'crypto';

export type DeepLinkType = 'web' | 'app';

export interface DeepLinkRoute {
name: string;
path: string;
allowedTypes: DeepLinkType[];
}

@Injectable()
export class DeepLinkService {
private readonly allowedRoutes: DeepLinkRoute[] = [
{ name: 'course', path: '/course', allowedTypes: ['web', 'app'] },
];

private readonly absoluteUrlPattern = /^(https?:\/\/|ftp:\/\/|\/\/)/i;
private readonly schemePattern = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/;
private readonly pathTraversalPattern = /(\.\.[\/\\])/;
private readonly injectionPattern = /[<>\{\}\\"'`]/;
private readonly validParamPattern = /^[a-zA-Z0-9\-_.~]+$/;

private readonly signingKey = 'teachlink-deeplink-signing-key';

validateRoute(route: string): boolean {
return this.allowedRoutes.some(r => r.path === route || r.name === route);
}

validateParam(value: string): string {
if (!value || typeof value !== 'string') {
throw new BadRequestException('Invalid parameter value');
}

const sanitized = value.trim();

if (!sanitized) {
throw new BadRequestException('Parameter value cannot be empty');
}

if (this.absoluteUrlPattern.test(sanitized)) {
throw new BadRequestException('Absolute URLs are not allowed');
}

if (this.schemePattern.test(sanitized)) {
throw new BadRequestException('External URL schemes are not allowed');
}

if (this.pathTraversalPattern.test(sanitized)) {
throw new BadRequestException('Path traversal is not allowed');
}

if (this.injectionPattern.test(sanitized)) {
throw new BadRequestException('Invalid characters in parameter');
}

if (!this.validParamPattern.test(sanitized)) {
throw new BadRequestException('Parameter contains invalid characters');
}

return sanitized;
}

buildDeepLink(type: DeepLinkType, route: string, param: string): string {
if (!this.validateRoute(route)) {
throw new BadRequestException(`Route '${route}' is not allowlisted`);
}

const sanitizedParam = this.validateParam(param);
const encodedParam = encodeURIComponent(sanitizedParam);

if (type === 'app') {
return `teachlink://${route}/${encodedParam}`;
}

return `/${route}/${encodedParam}`;
}

signLink(link: string): string {
const hmac = createHmac('sha256', this.signingKey);
hmac.update(link);
const signature = hmac.digest('hex');
const separator = link.includes('?') ? '&' : '?';
return `${link}${separator}sig=${signature}`;
}

verifyLink(link: string): boolean {
const sigParamIndex = link.search(/[?&]sig=/);
if (sigParamIndex === -1) return false;

const signature = link.slice(sigParamIndex + 5);
const basePath = link.slice(0, sigParamIndex);

const hmac = createHmac('sha256', this.signingKey);
hmac.update(basePath);
const expected = hmac.digest('hex');

if (signature.length !== expected.length) return false;

return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
}
36 changes: 36 additions & 0 deletions test/deep-link.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,40 @@ describe('DeepLinkController (e2e)', () => {

expect(response.header.location).toBe('teachlink://course/456');
});

it('GET /deep-link/course/:id should reject absolute URLs in id param', async () => {
const response = await request(app.getHttpServer())
.get('/deep-link/course/http%3A%2F%2Fevil.com')
.set('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
.expect(400);

expect(response.body.message).toContain('Absolute URLs are not allowed');
});

it('GET /deep-link/course/:id should reject external URL schemes in id param', async () => {
const response = await request(app.getHttpServer())
.get('/deep-link/course/javascript:alert(1)')
.set('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
.expect(400);

expect(response.body.message).toContain('External URL schemes are not allowed');
});

it('GET /deep-link/course/:id should reject path traversal in id param', async () => {
const response = await request(app.getHttpServer())
.get('/deep-link/course/%2E%2E%2Fadmin')
.set('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
.expect(400);

expect(response.body.message).toContain('Path traversal is not allowed');
});

it('GET /deep-link/course/:id should reject injection characters in id param', async () => {
const response = await request(app.getHttpServer())
.get('/deep-link/course/%3Cscript%3E')
.set('user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
.expect(400);

expect(response.body.message).toContain('Invalid characters in parameter');
});
});
Loading