How to implement hotlink protection and redirect requests to certain url pattern

Hello,
I am not well versed in programming. I know it is possible to implement hotlink protection as mentioned on this doc - https://developers.cloudflare.com/workers/examples/hot-link-protection

It is missing 2 things that I want to implement.

My use case is:

  1. When someone hotlinks my image, or try to visit image directly outside of host domain, I want them to be redirected to host domain with certain path, as that path shows the image on the website itself.

For example: Request is made https://sub.domain.tld/image/car.jpg
It should be redirected to https://domain.tld/image/car
If referrer is not hostname and trying to access image

  1. I also want to have a whitelist in the mix, but that be solved by using this script Cloudflare service worker hot link protection with whitelist · GitHub

Can someone help me modify the example in original documentation to implement the #1 use case mentioned above?

This code should work:

addEventListener('fetch', event => {
    event.respondWith(handleRequest(event))
})

const whitelist = ['domain1.com', 'domain2.com']

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'image' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			return Response.redirect(newUrl, 301)			
		}
	}
	
	return await fetch(request)
}
1 Like

Thank you so much for the response.

I am already using a script which was suggested by other user to server images from worker using backblaze b2 object storage.

my images are served from https://sub.domain.tld/i/image.ext

This is what I used to remove /file/bucket-name from url served from backblaze b2, and prettify the response. as mentioned above.

Here is the script I am currently using.

'use strict';
const b2Domain = 'sub.domain.tld';
const lensImagePath = '/i/'
const b2Bucket = 'bucket-name';

const b2UrlPath = `/file/${b2Bucket}/`;
addEventListener('fetch', event => {
	return event.respondWith(fileReq(event));
});

// define the file extensions we wish to add basic access control headers to
const corsFileTypes = ['png', 'bmp', 'jpg', 'gif', 'jpeg', 'webp'];

// backblaze returns some additional headers that are useful for debugging, but unnecessary in production. We can remove these to save some size
const removeHeaders = [
	'x-bz-content-sha1',
	'x-bz-file-id',
	'x-bz-file-name',
	'x-bz-info-src_last_modified_millis',
	'X-Bz-Upload-Timestamp',
	'Expires'
];
const expiration = 31536000; // override browser cache for images - 1 year

// define a function we can re-use to fix headers
const fixHeaders = function(url, status, headers){
	let newHdrs = new Headers(headers);
	// add basic cors headers for images
	if(corsFileTypes.includes(url.pathname.split('.').pop())){
		newHdrs.set('Access-Control-Allow-Origin', '*');
	}
	// override browser cache for files when 200
	if(status === 200){
		newHdrs.set('Cache-Control', "public, max-age=" + expiration);
	}else{
		// only cache other things for 5 minutes
		newHdrs.set('Cache-Control', 'public, max-age=300');
	}
	// set ETag for efficient caching where possible
	const ETag = newHdrs.get('x-bz-content-sha1') || newHdrs.get('x-bz-info-src_last_modified_millis') || newHdrs.get('x-bz-file-id');
	if(ETag){
		newHdrs.set('ETag', ETag);
	}
	// remove unnecessary headers
	removeHeaders.forEach(header => {
		newHdrs.delete(header);
	});
	return newHdrs;
};
async function fileReq(event){
	const cache = caches.default; // Cloudflare edge caching
	const url = new URL(event.request.url);
	if(url.host === b2Domain && !url.pathname.startsWith(b2UrlPath)){
		url.pathname = b2UrlPath + url.pathname;
		url.pathname = url.pathname.replace(lensImagePath, '/');
	}
	let response = await cache.match(url); // try to find match for this request in the edge cache
	if(response){
		// use cache found on Cloudflare edge. Set X-Worker-Cache header for helpful debug
		let newHdrs = fixHeaders(url, response.status, response.headers);
		newHdrs.set('X-Worker-Cache', "true");
		return new Response(response.body, {
			status: response.status,
			statusText: response.statusText,
			headers: newHdrs
		});
	}
	// no cache, fetch image, apply Cloudflare lossless compression
	response = await fetch(url);
	let newHdrs = fixHeaders(url, response.status, response.headers);
	response = new Response(response.body, {
		status: response.status,
		statusText: response.statusText,
		headers: newHdrs
	});

	event.waitUntil(cache.put(url, response.clone()));
	return response;
}

Is there a way to integrate your script into this one?

@notmyfirstid it is actually really simple to integrate both scripts.

  1. from the addEventListener you should first call the hotlinking script handleRequest passing the event object.
addEventListener('fetch', event => {
	event.respondWith(handleRequest(event));
});

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'image' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			return Response.redirect(newUrl, 301)			
		}
	}
	
	return await fileReq(event)
}

///your code goes here

I tried your solution. But it didn’t work. Requests trying to access images directly at https://sub.domain.tld/i/image.ext, doesn’t redirect to https://domain.com/i/image

It simply shows the image, same as before.
I feel like we’re close. And I am truly really grateful for you helping me on this. I am really not good at this.

To summarize what I am trying to achieve.

  1. When an image request is made from the domain.tld itself, i.e. domain.tld is referrer; it should proceed normally, i.e. image should be served through: https://sub.domain.tld/i/image.ext
  2. If someone tries to access the same image file direct at https://sub.domain.tld/i/image.ext from their browser, they should be redirected to https://domain.tld/i/image (notice without extension as image is shown natively on the website itself).

Am I doing something wrong? This is the complete script I used as you suggested:

//redirect direct access to image

addEventListener('fetch', event => {
	event.respondWith(handleRequest(event));
});

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'image' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			return Response.redirect(newUrl, 301)			
		}
	}
	
	return await fileReq(event)
}

//url prettify

'use strict';
const b2Domain = 'sub.domain.com'; // configure this as per instructions above
const lensImagePath = '/i/'
const b2Bucket = 'bucket-name'; // configure this as per instructions above

const b2UrlPath = `/file/${b2Bucket}/`;
addEventListener('fetch', event => {
	return event.respondWith(fileReq(event));
});

// define the file extensions we wish to add basic access control headers to
const corsFileTypes = ['png', 'bmp', 'jpg', 'gif', 'jpeg', 'webp'];

// backblaze returns some additional headers that are useful for debugging, but unnecessary in production. We can remove these to save some size
const removeHeaders = [
	'x-bz-content-sha1',
	'x-bz-file-id',
	'x-bz-file-name',
	'x-bz-info-src_last_modified_millis',
	'X-Bz-Upload-Timestamp',
	'Expires'
];
const expiration = 31536000; // override browser cache for images - 1 year

// define a function we can re-use to fix headers
const fixHeaders = function(url, status, headers){
	let newHdrs = new Headers(headers);
	// add basic cors headers for images
	if(corsFileTypes.includes(url.pathname.split('.').pop())){
		newHdrs.set('Access-Control-Allow-Origin', '*');
	}
	// override browser cache for files when 200
	if(status === 200){
		newHdrs.set('Cache-Control', "public, max-age=" + expiration);
	}else{
		// only cache other things for 5 minutes
		newHdrs.set('Cache-Control', 'public, max-age=300');
	}
	// set ETag for efficient caching where possible
	const ETag = newHdrs.get('x-bz-content-sha1') || newHdrs.get('x-bz-info-src_last_modified_millis') || newHdrs.get('x-bz-file-id');
	if(ETag){
		newHdrs.set('ETag', ETag);
	}
	// remove unnecessary headers
	removeHeaders.forEach(header => {
		newHdrs.delete(header);
	});
	return newHdrs;
};
async function fileReq(event){
	const cache = caches.default; // Cloudflare edge caching
	const url = new URL(event.request.url);
	if(url.host === b2Domain && !url.pathname.startsWith(b2UrlPath)){
		url.pathname = b2UrlPath + url.pathname;
		url.pathname = url.pathname.replace(lensImagePath, '/');
	}
	let response = await cache.match(url); // try to find match for this request in the edge cache
	if(response){
		// use cache found on Cloudflare edge. Set X-Worker-Cache header for helpful debug
		let newHdrs = fixHeaders(url, response.status, response.headers);
		newHdrs.set('X-Worker-Cache', "true");
		return new Response(response.body, {
			status: response.status,
			statusText: response.statusText,
			headers: newHdrs
		});
	}
	// no cache, fetch image, apply Cloudflare lossless compression
	response = await fetch(url);
	let newHdrs = fixHeaders(url, response.status, response.headers);
	response = new Response(response.body, {
		status: response.status,
		statusText: response.statusText,
		headers: newHdrs
	});

	event.waitUntil(cache.put(url, response.clone()));
	return response;
}

Just wanted to inform that this also doesn’t work on its own.
sub.domain.tld doesn’t redirect to domain.tld when image file is requested.
So, maybe the problem is with original script itself?

Also, I would like to mention that sub.domain.tld is CNAME of S3 storage bucket url.

I forgot to mention that you should have deleted the original addEventListener, and leave the new one.

Delete this:

addEventListener('fetch', event => {
	return event.respondWith(fileReq(event));
});

Still nothing. Image is loading, but it is not redirected to the website when being accessed directly.

This is the code I am using after remove addEventListener as suggested by you.

//redirect direct access to image

addEventListener('fetch', event => {
	event.respondWith(handleRequest(event));
});

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'image' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			return Response.redirect(newUrl, 301)			
		}
	}
	
	return await fileReq(event)
}

//url prettify

'use strict';
const b2Domain = 'sub.domain.tld'; // configure this as per instructions above
const lensImagePath = '/i/'
const b2Bucket = 'bucket-name'; // configure this as per instructions above

const b2UrlPath = `/file/${b2Bucket}/`;

// define the file extensions we wish to add basic access control headers to
const corsFileTypes = ['png', 'bmp', 'jpg', 'gif', 'jpeg', 'webp'];

// backblaze returns some additional headers that are useful for debugging, but unnecessary in production. We can remove these to save some size
const removeHeaders = [
	'x-bz-content-sha1',
	'x-bz-file-id',
	'x-bz-file-name',
	'x-bz-info-src_last_modified_millis',
	'X-Bz-Upload-Timestamp',
	'Expires'
];
const expiration = 31536000; // override browser cache for images - 1 year

// define a function we can re-use to fix headers
const fixHeaders = function(url, status, headers){
	let newHdrs = new Headers(headers);
	// add basic cors headers for images
	if(corsFileTypes.includes(url.pathname.split('.').pop())){
		newHdrs.set('Access-Control-Allow-Origin', '*');
	}
	// override browser cache for files when 200
	if(status === 200){
		newHdrs.set('Cache-Control', "public, max-age=" + expiration);
	}else{
		// only cache other things for 5 minutes
		newHdrs.set('Cache-Control', 'public, max-age=300');
	}
	// set ETag for efficient caching where possible
	const ETag = newHdrs.get('x-bz-content-sha1') || newHdrs.get('x-bz-info-src_last_modified_millis') || newHdrs.get('x-bz-file-id');
	if(ETag){
		newHdrs.set('ETag', ETag);
	}
	// remove unnecessary headers
	removeHeaders.forEach(header => {
		newHdrs.delete(header);
	});
	return newHdrs;
};
async function fileReq(event){
	const cache = caches.default; // Cloudflare edge caching
	const url = new URL(event.request.url);
	if(url.host === b2Domain && !url.pathname.startsWith(b2UrlPath)){
		url.pathname = b2UrlPath + url.pathname;
		url.pathname = url.pathname.replace(lensImagePath, '/');
	}
	let response = await cache.match(url); // try to find match for this request in the edge cache
	if(response){
		// use cache found on Cloudflare edge. Set X-Worker-Cache header for helpful debug
		let newHdrs = fixHeaders(url, response.status, response.headers);
		newHdrs.set('X-Worker-Cache', "true");
		return new Response(response.body, {
			status: response.status,
			statusText: response.statusText,
			headers: newHdrs
		});
	}
	// no cache, fetch image, apply Cloudflare lossless compression
	response = await fetch(url);
	let newHdrs = fixHeaders(url, response.status, response.headers);
	response = new Response(response.body, {
		status: response.status,
		statusText: response.statusText,
		headers: newHdrs
	});

	event.waitUntil(cache.put(url, response.clone()));
	return response;
}

Here in handleRequest change path[0] === 'image' replacing the string image for whatever is the first item of the pathname you are using now.

Now I can´t figure out what is the first item of the pathname, at the beginning was just image, now it look like is an i.

I just tested the previous example handleRequest is working as it should be.

I understand it must be frustrating. And you’ve been so patient with me.
If I could send you a DM, I would share more details about the url, and other personally identifiable details.
But, the original image url has this pattern
https://sub.domain.tld/i/image.ext

and I am trying to redirect this to https://domain.tld/i/image when someone tries to access the image directly.

Now, the images are stored on S3 compatible object storage backblaze b2. So, subdomains that serve images are CNAME to backblaze bucket url.

The backblaze b2 objects has url pattern of https://sub.domain.tld/file/buck-name/image.ext ; which I prettify using cloudflare workers and serve the requests coming from my website as https://sub.domain.tld/i/image.ext
When this request is made worker fetches the image from backblaze b2, and serves the image to modified url, thus accepting the request with prettified url that doesn’t reveal public bucket name.

I made the change as suggested by you. But, it is still showing images on the browser requests without redirecting.

//redirect direct access to image

addEventListener('fetch', event => {
	event.respondWith(handleRequest(event));
});

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'i' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			return Response.redirect(newUrl, 301)			
		}
	}
	
	return await fileReq(event)
}

//url prettify

'use strict';
const b2Domain = 'sub.domain.tld'; // configure this as per instructions above
const lensImagePath = '/i/'
const b2Bucket = 'bucket-name'; // configure this as per instructions above

const b2UrlPath = `/file/${b2Bucket}/`;

// define the file extensions we wish to add basic access control headers to
const corsFileTypes = ['png', 'bmp', 'jpg', 'gif', 'jpeg', 'webp'];

// backblaze returns some additional headers that are useful for debugging, but unnecessary in production. We can remove these to save some size
const removeHeaders = [
	'x-bz-content-sha1',
	'x-bz-file-id',
	'x-bz-file-name',
	'x-bz-info-src_last_modified_millis',
	'X-Bz-Upload-Timestamp',
	'Expires'
];
const expiration = 31536000; // override browser cache for images - 1 year

// define a function we can re-use to fix headers
const fixHeaders = function(url, status, headers){
	let newHdrs = new Headers(headers);
	// add basic cors headers for images
	if(corsFileTypes.includes(url.pathname.split('.').pop())){
		newHdrs.set('Access-Control-Allow-Origin', '*');
	}
	// override browser cache for files when 200
	if(status === 200){
		newHdrs.set('Cache-Control', "public, max-age=" + expiration);
	}else{
		// only cache other things for 5 minutes
		newHdrs.set('Cache-Control', 'public, max-age=300');
	}
	// set ETag for efficient caching where possible
	const ETag = newHdrs.get('x-bz-content-sha1') || newHdrs.get('x-bz-info-src_last_modified_millis') || newHdrs.get('x-bz-file-id');
	if(ETag){
		newHdrs.set('ETag', ETag);
	}
	// remove unnecessary headers
	removeHeaders.forEach(header => {
		newHdrs.delete(header);
	});
	return newHdrs;
};
async function fileReq(event){
	const cache = caches.default; // Cloudflare edge caching
	const url = new URL(event.request.url);
	if(url.host === b2Domain && !url.pathname.startsWith(b2UrlPath)){
		url.pathname = b2UrlPath + url.pathname;
		url.pathname = url.pathname.replace(lensImagePath, '/');
	}
	let response = await cache.match(url); // try to find match for this request in the edge cache
	if(response){
		// use cache found on Cloudflare edge. Set X-Worker-Cache header for helpful debug
		let newHdrs = fixHeaders(url, response.status, response.headers);
		newHdrs.set('X-Worker-Cache', "true");
		return new Response(response.body, {
			status: response.status,
			statusText: response.statusText,
			headers: newHdrs
		});
	}
	// no cache, fetch image, apply Cloudflare lossless compression
	response = await fetch(url);
	let newHdrs = fixHeaders(url, response.status, response.headers);
	response = new Response(response.body, {
		status: response.status,
		statusText: response.statusText,
		headers: newHdrs
	});

	event.waitUntil(cache.put(url, response.clone()));
	return response;
}

Hello, I couldn’t find this: const whitelist = ['domain1.com', 'domain2.com'], you can put it at the top of the script.

Without whitelist the script should be throwing an error.

Try to test this code in preview mode and tell me what results in the console.

https://example.com/i/image/dog.jpg

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'i' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		console.log('valid pathname')
		console.log({refererHostname, hostname})
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			
			console.log('invalid hostname')
			console.log(`redirect to ${newUrl}`)
			
			return Response.redirect(newUrl, 301)			
		}
	}
	
	console.log(`not redirecting`)
	
	return await fileReq(event)
}

Got this from console:

not redirecting worker.js:36

worker.js:38 Uncaught (in promise) ReferenceError: fileReq is not defined at handleRequest (worker.js:38) at worker.js:6

handleRequest @ worker.js:38
(anonymous) @ worker.js:6

Uncaught (in response) ReferenceError: fileReq is not defined

Just wanted to inform that url doesn’t have “image” in the pathname. It is just /i/dog.jpg

As long as path[0] == 'i', it will be executed as it is expected.

What does the console tell you?

I posted console log above.

Keep the other parts and just replace the handleRequest.

Sorry, but replace handleRequest with what?

Yes, just the function, leaving the other parts of the code.

I am sorry for being naive.
I still don’t understand what you want me to do.

Can you please modify this code to tell me what you mean?

//redirect direct access to image

const whitelist = ['domain.tld']

addEventListener('fetch', event => {
	event.respondWith(handleRequest(event));
});

const handleRequest = async (event) => {
	const {request} = event
	const url = new URL(request.url)
	const {hostname, pathname, protocol} = url
	const referer = request.headers.get('Referer')
	let path = pathname.split('/') || []
	path = path.filter(i => i) || []
	
	if(referer && path[0] === 'i' && path.length > 1 && pathname.includes('.'))
	{
		const refererHostname = new URL(referer).hostname
		
		console.log('valid pathname')
		console.log({refererHostname, hostname})
		
		if (refererHostname !== hostname && !whitelist.includes(refererHostname))
		{
			const filePath = pathname.substr(0, pathname.lastIndexOf('.')) + ''
			const newUrl = new URL(filePath, `${protocol}//domain.tld`).href
			
			console.log('invalid hostname')
			console.log(`redirect to ${newUrl}`)
			
			return Response.redirect(newUrl, 301)			
		}
	}
	
	console.log(`not redirecting`)
	
	return await fileReq(event)
}

Btw, when I replace handleRequest with fileReq on addEventListener,

addEventListener(‘fetch’, event => {
event.respondWith(fileReq(event));
});

I get this error:

And when I replace fileReq at last line with handleRequest,

return await handleRequest(event)

I get this error:

Do some research on the Accept request header. This header is different when someone accesses a URL directly than when it is embed as an image which would allow you to detect and redirect direct requests.

1 Like