feat: add proper validation and normalization on input
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { normalizeUrl, isValidUrlForShortening } from '../utils/url';
|
||||
import {
|
||||
shortenUrl,
|
||||
selectShortUrl,
|
||||
@@ -12,10 +13,10 @@ import {
|
||||
const Home: React.FC = () => {
|
||||
/* component level state */
|
||||
const [url, setUrl] = useState('');
|
||||
const [urlError, setUrlError] = useState('');
|
||||
|
||||
/* global state */
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const shortUrl = useAppSelector(selectShortUrl);
|
||||
const status = useAppSelector(selectUrlStatus);
|
||||
const error = useAppSelector(selectUrlError);
|
||||
@@ -29,7 +30,13 @@ const Home: React.FC = () => {
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (url.trim() !== '') {
|
||||
dispatch(shortenUrl(url));
|
||||
if (!isValidUrlForShortening(url)) {
|
||||
setUrlError('Hmm, that URL doesn’t look right. Try something like https://example.com.');
|
||||
dispatch(clearShortUrl())
|
||||
} else {
|
||||
setUrlError('');
|
||||
dispatch(shortenUrl(normalizeUrl(url)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,6 +49,7 @@ const Home: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setUrl('')
|
||||
dispatch(clearShortUrl())
|
||||
};
|
||||
|
||||
@@ -68,6 +76,10 @@ const Home: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{urlError && (
|
||||
<p className="text-red-600 text-sm">{urlError}</p>
|
||||
)}
|
||||
|
||||
{status === 'loading' && (
|
||||
<p className="text-gray-600 text-sm">Shortening your URL...</p>
|
||||
)}
|
||||
|
||||
87
frontend/src/utils/url.ts
Normal file
87
frontend/src/utils/url.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
export function normalizeUrl(input: string) {
|
||||
if (!/^https?:\/\//i.test(input)) {
|
||||
return `http://${input}`;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function isValidUrlForShortening(input: string): boolean {
|
||||
try {
|
||||
// Normalize protocol
|
||||
const hasProtocol = /^https?:\/\//i.test(input);
|
||||
const inputUrl = hasProtocol ? input : `http://${input}`;
|
||||
|
||||
const parsed = new URL(inputUrl);
|
||||
|
||||
// 1. Protocol must be http or https
|
||||
if (hasProtocol && !['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Port (if present) must be valid
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : null;
|
||||
if (port !== null && (port < 1 || port > 65535)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Hostname must exist and be valid
|
||||
const host = parsed.hostname;
|
||||
if (!host || host.toLowerCase() === 'localhost') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ❌ Reject trailing dot (e.g., example.com.)
|
||||
if (inputUrl.includes(`${host}.`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ❌ Reject double dots (e.g., example..com)
|
||||
if (host.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ❌ Reject domains with leading or trailing hyphens
|
||||
const domainParts = host.split('.');
|
||||
if (
|
||||
domainParts.length < 2 ||
|
||||
domainParts.some(
|
||||
part => part.startsWith('-') || part.endsWith('-') || part.length === 0
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ✅ Accept valid IPv4 address
|
||||
const ipMatch = host.match(/^(\d{1,3}\.){3}\d{1,3}$/);
|
||||
if (ipMatch) {
|
||||
const octets = host.split('.').map(Number);
|
||||
if (octets.length === 4 && octets.every(n => n >= 0 && n <= 255)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the authority section safely
|
||||
const authoritySection = (() => {
|
||||
const parts = input.split('//');
|
||||
if (parts.length < 2) return '';
|
||||
const afterScheme = parts[1];
|
||||
return afterScheme.split('/')[0]; // stops before path/query
|
||||
})();
|
||||
|
||||
// Count @ signs in authority only
|
||||
const atCount = (authoritySection.match(/@/g) || []).length;
|
||||
if (atCount > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Length limit
|
||||
if (inputUrl.length > 2048) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user