diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..bf2e7648 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +shamefully-hoist=true diff --git a/src/deep-link/deep-link.controller.ts b/src/deep-link/deep-link.controller.ts index 19fb91b3..4768ae68 100644 --- a/src/deep-link/deep-link.controller.ts +++ b/src/deep-link/deep-link.controller.ts @@ -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 = { @@ -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); } } diff --git a/src/deep-link/deep-link.module.ts b/src/deep-link/deep-link.module.ts index 66c35693..8b41c4c3 100644 --- a/src/deep-link/deep-link.module.ts +++ b/src/deep-link/deep-link.module.ts @@ -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 {} diff --git a/src/deep-link/deep-link.service.spec.ts b/src/deep-link/deep-link.service.spec.ts new file mode 100644 index 00000000..801a8ef8 --- /dev/null +++ b/src/deep-link/deep-link.service.spec.ts @@ -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,')).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('