feat: add proper validation and normalization on input

This commit is contained in:
2025-07-25 04:41:35 +00:00
parent b184c639b0
commit 79659a7fc4
2 changed files with 101 additions and 2 deletions

View File

@@ -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 doesnt 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
View 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;
}
}