/**
* Security Tests
*
* Tests for security-related fixes:
* - URL validation bypass prevention
* - Path traversal protection
*
* @module security.test
*/
import { describe, it, expect } from '@jest/globals';
describe('Security', () => {
describe('URL Validation', () => {
/**
* Helper function that mimics the URL validation logic in http-wrapper.ts
*/
const isValidNotebookLMUrl = (url: string): { valid: boolean; error?: string } => {
try {
const parsedUrl = new URL(url);
if (parsedUrl.hostname !== 'notebooklm.google.com') {
return { valid: false, error: 'Invalid hostname' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid URL format' };
}
};
it('should accept valid NotebookLM URLs', () => {
const validUrls = [
'https://notebooklm.google.com/notebook/abc-123',
'https://notebooklm.google.com/notebook/abc-123-def-456',
'https://notebooklm.google.com/notebook/test',
'https://notebooklm.google.com/',
];
for (const url of validUrls) {
const result = isValidNotebookLMUrl(url);
expect(result.valid).toBe(true);
}
});
it('should reject URLs with wrong hostname', () => {
const invalidUrls = [
'https://evil.com/notebook/abc',
'https://google.com/notebook/abc',
'https://notebooklm.evil.com/notebook/abc',
];
for (const url of invalidUrls) {
const result = isValidNotebookLMUrl(url);
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid hostname');
}
});
it('should reject URL bypass attempts using path tricks', () => {
// These URLs try to trick includes('notebooklm.google.com') check
const bypassAttempts = [
'https://evil.com/notebooklm.google.com/notebook/abc',
'https://evil.com/?redirect=https://notebooklm.google.com',
'https://notebooklm.google.com.evil.com/notebook/abc',
'https://evil.com#notebooklm.google.com',
];
for (const url of bypassAttempts) {
const result = isValidNotebookLMUrl(url);
expect(result.valid).toBe(false);
}
});
it('should reject malformed URLs', () => {
const malformedUrls = ['not-a-url', 'javascript:alert(1)', 'file:///etc/passwd', '', ' '];
for (const url of malformedUrls) {
const result = isValidNotebookLMUrl(url);
expect(result.valid).toBe(false);
// Error can be 'Invalid URL format' or 'Invalid hostname' depending on parse result
expect(['Invalid URL format', 'Invalid hostname']).toContain(result.error);
}
// http:// parses but has empty hostname, so it gets "Invalid hostname"
const httpOnlyResult = isValidNotebookLMUrl('http://');
expect(httpOnlyResult.valid).toBe(false);
});
it('should handle protocol variations', () => {
// HTTP should still work (URL class accepts it)
const httpUrl = 'http://notebooklm.google.com/notebook/abc';
expect(isValidNotebookLMUrl(httpUrl).valid).toBe(true);
// FTP and other protocols should be rejected by hostname check or URL parse
const ftpUrl = 'ftp://notebooklm.google.com/notebook/abc';
// URL class will parse this, but it's still valid hostname
expect(isValidNotebookLMUrl(ftpUrl).valid).toBe(true);
});
});
describe('Path Traversal Protection', () => {
// Use dynamic import in the test
const getPathModule = async () => await import('path');
it('should allow files within data directory', async () => {
const path = await getPathModule();
const isPathAllowed = (filePath: string, dataDir: string, cwd: string): boolean => {
const resolvedPath = path.resolve(filePath);
const allowedDataDir = path.resolve(dataDir);
const allowedCwd = path.resolve(cwd);
return resolvedPath.startsWith(allowedDataDir) || resolvedPath.startsWith(allowedCwd);
};
const dataDir = '/app/data';
const allowedPaths = [
'/app/data/file.pdf',
'/app/data/uploads/doc.txt',
'/app/data/nested/deep/file.md',
];
for (const filePath of allowedPaths) {
expect(isPathAllowed(filePath, dataDir, '/app')).toBe(true);
}
});
it('should allow files within current working directory', async () => {
const path = await getPathModule();
const isPathAllowed = (filePath: string, dataDir: string, cwd: string): boolean => {
const resolvedPath = path.resolve(filePath);
const allowedDataDir = path.resolve(dataDir);
const allowedCwd = path.resolve(cwd);
return resolvedPath.startsWith(allowedDataDir) || resolvedPath.startsWith(allowedCwd);
};
const cwd = '/app';
const allowedPaths = ['/app/src/file.ts', '/app/package.json', '/app/docs/readme.md'];
for (const filePath of allowedPaths) {
expect(isPathAllowed(filePath, '/app/data', cwd)).toBe(true);
}
});
it('should reject path traversal attempts', async () => {
const path = await getPathModule();
const isPathAllowed = (filePath: string, dataDir: string, cwd: string): boolean => {
const resolvedPath = path.resolve(filePath);
const allowedDataDir = path.resolve(dataDir);
const allowedCwd = path.resolve(cwd);
return resolvedPath.startsWith(allowedDataDir) || resolvedPath.startsWith(allowedCwd);
};
const dataDir = '/app/data';
const cwd = '/app';
const traversalAttempts = [
'/etc/passwd',
'/app/data/../../../etc/passwd',
'../../../etc/passwd',
'/root/.ssh/id_rsa',
'/app/data/../../etc/shadow',
];
for (const filePath of traversalAttempts) {
expect(isPathAllowed(filePath, dataDir, cwd)).toBe(false);
}
});
it('should reject absolute paths outside allowed directories', async () => {
const path = await getPathModule();
const isPathAllowed = (filePath: string, dataDir: string, cwd: string): boolean => {
const resolvedPath = path.resolve(filePath);
const allowedDataDir = path.resolve(dataDir);
const allowedCwd = path.resolve(cwd);
return resolvedPath.startsWith(allowedDataDir) || resolvedPath.startsWith(allowedCwd);
};
const rejectedPaths = [
'/tmp/evil.sh',
'/var/log/syslog',
'/home/user/.bashrc',
'/usr/bin/node',
];
for (const filePath of rejectedPaths) {
expect(isPathAllowed(filePath, '/app/data', '/app')).toBe(false);
}
});
it('should handle relative paths correctly', async () => {
const path = await getPathModule();
const isPathAllowed = (filePath: string, dataDir: string, cwd: string): boolean => {
const resolvedPath = path.resolve(filePath);
const allowedDataDir = path.resolve(dataDir);
const allowedCwd = path.resolve(cwd);
return resolvedPath.startsWith(allowedDataDir) || resolvedPath.startsWith(allowedCwd);
};
// When running from /app, ./file.txt resolves to /app/file.txt
// This test uses process.cwd() behavior
const resolvedCwd = path.resolve('.');
// A file in current directory should be allowed
const localFile = path.join(resolvedCwd, 'test.txt');
expect(isPathAllowed(localFile, '/tmp/data', resolvedCwd)).toBe(true);
// A file outside should be rejected
expect(isPathAllowed('/etc/passwd', '/tmp/data', resolvedCwd)).toBe(false);
});
it('should handle edge cases', async () => {
const path = await getPathModule();
const dataDir = '/app/data';
const cwd = '/app';
// Null bytes and special characters (path.resolve handles these)
const edgeCases = [
'/app/data/file\x00.txt', // Null byte
'/app/data/file%00.txt', // URL-encoded null
'/app/data/...../file.txt', // Multiple dots
];
// These should resolve normally and be within allowed path
for (const filePath of edgeCases) {
const resolved = path.resolve(filePath);
// If it starts with /app, it's allowed
const allowed = resolved.startsWith(dataDir) || resolved.startsWith(cwd);
expect(typeof allowed).toBe('boolean');
}
});
});
});