feat: add proper validation and normalization on input
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
import { useAppDispatch, useAppSelector } from '../app/hooks';
|
||||||
import { copyToClipboard } from '../utils/clipboard';
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
|
import { normalizeUrl, isValidUrlForShortening } from '../utils/url';
|
||||||
import {
|
import {
|
||||||
shortenUrl,
|
shortenUrl,
|
||||||
selectShortUrl,
|
selectShortUrl,
|
||||||
@@ -12,10 +13,10 @@ import {
|
|||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
/* component level state */
|
/* component level state */
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
|
const [urlError, setUrlError] = useState('');
|
||||||
|
|
||||||
/* global state */
|
/* global state */
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const shortUrl = useAppSelector(selectShortUrl);
|
const shortUrl = useAppSelector(selectShortUrl);
|
||||||
const status = useAppSelector(selectUrlStatus);
|
const status = useAppSelector(selectUrlStatus);
|
||||||
const error = useAppSelector(selectUrlError);
|
const error = useAppSelector(selectUrlError);
|
||||||
@@ -29,7 +30,13 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (url.trim() !== '') {
|
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 = () => {
|
const handleClear = () => {
|
||||||
|
setUrl('')
|
||||||
dispatch(clearShortUrl())
|
dispatch(clearShortUrl())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +76,10 @@ const Home: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{urlError && (
|
||||||
|
<p className="text-red-600 text-sm">{urlError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === 'loading' && (
|
{status === 'loading' && (
|
||||||
<p className="text-gray-600 text-sm">Shortening your URL...</p>
|
<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