Til kamp mod phishing på lnk.dk: del 2

Mit første naive forsøg på at forhindre kriminelles brug af lnk.dk til at pege på diverse phishing-sider lykkedes, mildt sagt, ikke.

Nu har jeg taget skrappere midler i brug.

I min models.py i min Django-applikation, tilføjer jeg et felt til at gemme ip-adresse på den, der har oprettet et link, og et felt til at markere, om et link er usikkert:

class Link(models.Model):
    # Short link is only lower case
    def save(self, *args, **kwargs):
        self.shortlink = self.shortlink.lower()
        return super(Link, self).save(*args, **kwargs)

    destination = models.URLField('Destinationslink', max_length=65535, validators=[validate_destination])
    shortlink = models.SlugField('Kort link', max_length=100, unique=True, allow_unicode=False, validators=[validate_shortlink])
    LINK_TYPE_CHOICES = (
        ('automatic', 'Automatisk'),
        ('manual', 'Manuelt'),
    )   
    submitter_ip = models.GenericIPAddressField(null=True)
    unsafe_link = models.BooleanField(default=False)
    type = models.CharField('Type', max_length=10, choices=LINK_TYPE_CHOICES)
    date = models.DateTimeField(default=timezone.now, editable=False)

Derudover tilføjer jeg tabeller til at kunne blokere for ip-adresser og domæner, der ikke skal kunne oprette brugbare links:

class Ban(models.Model):
    banned_ip = models.GenericIPAddressField(unique=True)

class BanDomain(models.Model):
    banned_domain = models.CharField(max_length=255, unique=True)

Med det på plads tilpasser jeg min logik til oprettelse af links i views.py sådan, at:

  • kun brugere med ip-adresser kan oprette links,
  • links bliver tjekket med Google Safe Browsing (efter råd på Twitter)
  • links til domæner og fra ip-adresser, der er blokeret, bliver automatisk markeret som usikre

Her er funktionen til at tjekke links op mod Google Safe Browsing:

# Google safe browsing API check function
def is_url_google_safe_browsing_safe(url):
    params = {
        'key': ''
    }
    json  = {
        "client": {
            "clientId":      "lnk.dk",
            "clientVersion": "1.0"
        },
        "threatInfo": {
            "threatTypes":      ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE"],
            "platformTypes":    ["ANY_PLATFORM"],
            "threatEntryTypes": ["URL"],
            "threatEntries": [
                {"url": url}
          ]
        }
     }
    api_url = 'https://safebrowsing.googleapis.com/v4/threatMatches:find'
    try:
        response_json = requests.post(api_url, params=params, json=json).json()
        if response_json:
            return True
        else:
            return False
    # If something unexpected is returned from Google, link creation is allowed
    except:
        return False

Og her er min nye logik til at tjekke modtagne links. Læg mærke til, at brugere uden ip-adresse automatisk bliver rickrolled

if form.is_valid():
	client_ip, is_routable = get_client_ip(request)
	# Hiding your IP seems illegit, so user is rickrolled
	if client_ip is None:
		return HttpResponseRedirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
	else:
		destination = form.cleaned_data['destination'] # Submitted destination
		shortlink = form.cleaned_data['shortlink'] # Submitted slug
		
		# Google Safe Browsing check
		unsafe_url = is_url_google_safe_browsing_safe(destination)
		
		# Ban domain check
		domain_info = extract(destination)
		domain = domain_info.domain + '.' + domain_info.suffix                    
		domain_ban = BanDomain.objects.filter(banned_domain=domain)
		if len(domain_ban) > 0:
			banned_domain = True
		else:
			banned_domain = False
		
		# Ban ip check
		ip_ban = Ban.objects.filter(banned_ip=client_ip)
		if len(ip_ban) > 0:
			banned_ip = True
		else:
			banned_ip = False

Til sidst har jeg forsøgt at narre phisherne ved at links til usikre sider virker, for den, der selv har oprettet linket. Alle andre bliver rickrollet, hvis de klikker på et usikkert link:

# Short link redirect to destination URL
def redirect(request, shortlink):
    # Query the database for short link, if there is a hit, redirect to destination URL
    # In case of uppercase characters in user input shortlink, the link is made lowercase
    # Also, check if ip is banned
    try:
        client_ip, is_routable = get_client_ip(request)
        link = Link.objects.get(shortlink=shortlink.lower())
        # Legit users are rickrolled when an unsafe link is visited from an IP that is not banned
        if link.unsafe_link == True and not client_ip == link.submitter_ip:
            return HttpResponseRedirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
        # For safe links AND for unsafe links visited from banned IPs, user is redirected to destination
        else:
            # If there's a referer and it's the same as the destination link, show a 404 to avoid an endless loop
            if 'HTTP_REFERER' in request.META and link.destination == request.META['HTTP_REFERER']:
                return render(request, 'links/404.html', status=404)
            return HttpResponseRedirect(link.destination)
    # In case of an error, show 404 page
    except:
        return render(request, 'links/404.html', status=404)

Til kamp mod phishing på lnk.dk

Der er desværre nogle kriminelle, der har opdaget min kortlink-service lnk.dk og bruger siden til at lave korte links, der peger på forskellige phishing-formularer. De fleste på fransk, enkelte på dansk.

Jeg vil helst kun have, at min side bruges til lovlige formål, og derfor har jeg i første omgang lavet et kontrolspørgsmål i formularen til at oprette links. Jeg håber, at det kun er ærlige mennesker, der kan svare på spørgsmålet, og at det er relativt nemt for dem:

Nyt kontrolspørgsmål om en kendt dansk cykelrytter på lnk.dk

For at implementere det nye felt, redigerede jeg min Django-applikations forms.py med feltet og krav til validering:

from django.forms import ModelForm
from django import forms
from .models import Link
from django.core.exceptions import ValidationError

class LinkForm(ModelForm):
    everyoneknows = forms.CharField(label='Hvad er fornavnet på cykelrytteren, der vandt Tour de France for mænd i 2022?', error_messages={'required': 'Indtast cykelrytterens fornavn'})

    def clean_everyoneknows(self):
        answer = self.cleaned_data['everyoneknows'].lower()
        if answer != 'jonas':
            raise ValidationError("Det fornavn, du har indtastet, er forkert.")
        return answer
    
    def __init__(self, *args, **kwargs):
        super(LinkForm, self).__init__(*args, **kwargs)
        self.fields['destination'].widget.attrs['placeholder'] = 'https://eksempel.dk/meget/lang/url'
        self.fields['shortlink'].widget.attrs['placeholder'] = 'eksempel'
        self.fields['shortlink'].label_suffix = ""  # Remove colon after label
        self.fields['shortlink'].required = False   # Not required in form

    def clean_shortlink(self):
        shortlink = self.cleaned_data['shortlink']
        return shortlink.lower()
    
    class Meta:
        model = Link
        fields = ['destination', 'shortlink']
        labels = {
            'shortlink': ('Evt. selvvalgt kort link:'),
        }
        error_messages = {
            'destination': {
                'max_length': ('Din destinationsurl er for lang til denne kortlinkservice.'),
                'invalid': ('Din destinationsurl er ikke en gyldig adresse. Husk http://, https:// eller ftp:// foran dit link, hvis du har glemt det.'),
            },
            'shortlink': {
                'unique': ('Det selvvalgte link, du har valgt, er allerede i brug. Find på et andet.'),
                'max_length': ('Dit selvvalgte link må maksimalt være 100 tegn langt.'),
                'invalid': ('Du kan kun bruge bogstaver (dog ikke æ, ø, å - kun ASCII-tegnsættet), tal, bindestreg og understreg i din selvvalgte adresse.'),
            }
        }

Det bliver spændende at se, om ændringen har nogen effekt.

lnk.dk er i luften med dejlige forkortede links

For noget tid siden fortalte jeg om en prototype på en linkforkorter, jeg havde lavet. Den var ikke særlig brugbar, for den lå på wallnot.dk og lavede derfor ikke specielt korte links.

Efter en behagelig og ukompliceret dialog med de flinke advokater hos Kønig Advokater, der ejede brugsretten til domænenavnet lnk.dk, har jeg fået lov til at overtage lnk.dk – og nu er en opdateret udgave af min kortlinkservice i luften.

lnk.dk kan du lave automatisk generede korte links (tænk lnk.dk/ab0g) eller selv vælge, hvad dit korte link skal hedde (tænk lnk.dk/morten).

Som sædvanlig har jeg brugt Django til arbejdet.

I models.py definerer jeg datamodellen, lidt ekstra validering til brug i formularen til at lave korte links, og hvad jeg gerne vil se i admin-interfacet for siden:

from django.db import models
from django.utils import timezone
from django.contrib import admin
from django.core.exceptions import ValidationError

def validate_destination(destination):
	if "lnk.dk/" in destination.lower():
		raise ValidationError('For at undgå risiko for uendelige viderestillinger, kan du ikke tilføje korte links fra lnk.dk som destination.')
		
def validate_shortlink(shortlink):
	if shortlink == "om":
		raise ValidationError('Det korte link "om" bruger lnk.dk til at fortælle om lnk.dk. Vælg et andet selvvalgt kort link.')
	elif shortlink == "administration":
		raise ValidationError('Det korte link "administration" bruger lnk.dk til at administrere lnk.dk. Vælg et andet selvvalgt kort link.')

class Link(models.Model):
	destination = models.URLField('Destinationslink', max_length=65535, validators=[validate_destination])
	shortlink = models.SlugField('Kort link', max_length=100, unique=True, allow_unicode=True, validators=[validate_shortlink])
	LINK_TYPE_CHOICES = (
		('automatic', 'Automatisk'),
		('manual', 'Manuelt'),
	)	
	type = models.CharField('Type', max_length=10, choices=LINK_TYPE_CHOICES)
	date = models.DateTimeField(default=timezone.now, editable=False)
	
class LinkAdmin(admin.ModelAdmin):
	list_display = ('destination','shortlink','type','date')
	list_filter = ('type', )
	search_fields = ['destination']

I forms.py definerer jeg formularen, som brugeren indtaster sit lange link og evt. et selvvalgt kort link i. Jeg forsøger også at formulere nogle forståelige fejlmeddelelser:

from django.forms import ModelForm
from .models import Link

class LinkForm(ModelForm):
	def __init__(self, *args, **kwargs):
		super(LinkForm, self).__init__(*args, **kwargs)
		self.fields['destination'].widget.attrs['placeholder'] = 'https://eksempel.dk/meget/lang/url'
		self.fields['shortlink'].widget.attrs['placeholder'] = 'eksempel'
		self.fields['shortlink'].label_suffix = ""	# Remove colon after label
		self.fields['shortlink'].required = False	# Not required in form
	
	class Meta:
		model = Link
		fields = ['destination', 'shortlink']
		labels = {
			'shortlink': ('Evt. selvvalgt kort link, lnk.dk/'),
		}
		error_messages = {
			'destination': {
				'max_length': ('Din destinationsurl er for lang til denne kortlinkservice.'),
				'invalid': ('Din destinationsurl er ikke en gyldig adresse. Husk http://, https:// eller ftp:// foran dit link, hvis du har glemt det.'),
			},
			'shortlink': {
				'unique': ('Det selvvalgte link, du har valgt, er allerede i brug. Find på et andet.'),
				'max_length': ('Dit selvvalgte link må maksimalt være 100 tegn langt.'),
				'invalid': ('Du kan kun bruge unicode-bogstaver, cifre, bindestreg og understreg i din selvvalgte adresse.'),
			}
		}

Mine visninger forberedes i views.py som har:

  • En funktion til autogenerede kortlinks. Linket tilføjet en tilfældig streng hashes til en ny tilfældig streng. Der tilføjes en tilfældig streng hver gang for at sikre, at der genereres en ny streng hver gang (for at undgå en uendelig løkke, hvis et links hash-værdi skulle kollidere med et andet links hashværdi).
  • En visning til forsiden med dens formular, validering af formularen og visning af kortlink og eventuelle fejl.
  • En visning, der sørger for at viderestille fra et kort link til et destinationslink, hvis det korte link findes. Ellers vises en fejlside.
  • En visning til en “om lnk.dk”-side.
from django.shortcuts import render
from django.http import HttpResponseRedirect
from .models import Link
from .forms import LinkForm
from django.urls import reverse
import hashlib
import bcrypt

# Function to create a random hash to use as short link address
def create_shortlink(destination):
	salt = bcrypt.gensalt().decode()	# Random salt
	destination = destination+salt		# Salt added to destination URL
	hash = hashlib.md5(destination.encode()).hexdigest() # Hashed to alphanumeric string
	return hash[:4]	# First 5 characters of that string 

# Front page with a form to enter destination address. Short URL returned.
def linkindex(request):
	form = LinkForm()	# Loads form
	# If a destination is submitted, a short link is returned
	if request.method == 'POST':
		form = LinkForm(request.POST) # Form instance with submitted data
		# Check whether submitted data is valid
		if form.is_valid():
			destination = form.cleaned_data['destination'] # Submitted destination
			shortlink = form.cleaned_data['shortlink'] # Submitted slug
		
			# User has specified a unique (validated) short link
			if shortlink:
				link = form.save(commit=False)
				link.type = "manual"
				link = form.save()
				site_url = reverse('redirect', args=[link.shortlink])
				sharelink = request.build_absolute_uri(site_url)
			# User wants an automatic link
			else:
				# If a short link with same destionation of same type already exists,
				# it is fetched from database  and served. No need to use up a new
				# URL for the same destination.
				try:
					link = Link.objects.get(destination=destination, type="automatic")
					site_url = reverse('redirect', args=[link.shortlink])
					sharelink = request.build_absolute_uri(site_url)
				# If a link of same type with same destination does not exist, one is
				# created.
				except:	
					# Loop to create a unique hash value for short link
					unique_link = False
					while unique_link == False:
						hash = create_shortlink(destination)	# Return hash
						# First we check whether the hash is a duplicate
						try:
							Link.objects.get(shortlink=hash)	# Check whether hash is used
						# If not a duplicate, an error is thrown, and we can save the hash
						except:
							link = form.save(commit=False)	# Prepare to save form destination data and hash
							link.shortlink = hash	# Sets short link to hash value
							link.type = "automatic"
							link.save()	# Saves destination and short link to database
							site_url = reverse('redirect', args=[link.shortlink])
							sharelink = request.build_absolute_uri(site_url)
							unique_link = True	# If check causes error, hash is unused, exit loop
			context = {'sharelink': sharelink, 'form': form}	# Dictionary with variables used in template
			return render(request, 'links/index.html', context)
		# If form is invalid, check whether a user is trying to create a duplicate manual
		# shortlink.
		else:
			# Check if there's a valid destination link
			if 'destination' in form.cleaned_data:
				# Check if there's a shortlink that's not unique
				try:
					link = Link.objects.get(shortlink=form.data['shortlink'])
					# If so, check whether the destination is the same
					if form.cleaned_data['destination'] == link.destination:
						# Show sharelink to user
						site_url = reverse('redirect', args=[link.shortlink])
						sharelink = request.build_absolute_uri(site_url)
						form.errors['shortlink'] = "" # Error replaced by empty string
					# Render form with sharelink already used error
					else:
						sharelink = ""
				# Render form with error
				except:	
					sharelink = ""
			# Render form with errors	
			else:
				sharelink = ""
		context = {'form': form, 'sharelink': sharelink}
		return render(request, 'links/index.html', context)
	# Render page with form before user has submitted
	context = {'form': form}
	return render(request, 'links/index.html', context)

# Short link redirect to destination URL
def redirect(request, shortlink):
	# Query the database for short link, if there is a hit, redirect to destination URL
	try:
		link = Link.objects.get(shortlink=shortlink)
		return HttpResponseRedirect(link.destination)
	# An error means the short link doesn't exist, so error 404 is shown
	except:
		return render(request, 'links/404.html', status=404)

# About page		
def about(request):
	context = {'request': request}
	return render(request, 'links/about.html', context)	

urls.py sørger for at forbinde den adressse, brugeren har tastet i browseren, med de rette visninger fra views.py:

from django.urls import path
from . import views

urlpatterns = [
    path('', views.linkindex, name='index'),
	path('om', views.about, name='about'),
    path('<shortlink>', views.redirect, name='redirect'),
]

Endelig har jeg skabelon-filer, der sørger for selve html-koden på siden. base.html er min overordnede skabelon med det overordnede design, meta-tags, sidefod osv.:

{% load static %}{% spaceless %}<!doctype html>
<html lang="da">
	<head>
		<title>lnk.dk: Danmarks korteste links</title>
		<meta charset="utf-8"/>
		<meta name="description" content="Lav de korteste korte kortlinks gratis på lnk.dk. Fri for annoncer og overvågning.">
		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
		<link rel="stylesheet" href="{% static "links/style.css" %}">
		<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
		<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
		<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
		<link rel="manifest" href="/site.webmanifest">
		<meta name="theme-color" content="#ffffff">
	</head>
<body>
{% block content %}{% endblock %}
<div class="footer">
	<p>Lav de korteste korte links gratis på lnk.dk. Fri for annoncer og overvågning. <a href="{% url 'about' %}">Om lnk.dk</a></p>
</div>
</body>
</html>
{% endspaceless %}

Og her er skabelonen til forsiden, index.html:

{% extends "links/base.html" %}{% block content %}{% spaceless %}
<h1>Lav et kort link</h1>
<div class="content">
	{% if form %}
		<form method="post">
		{% csrf_token %}
		<div class="form_field">
			<div class="label">
				{{ form.destination.label_tag}}
			</div>
			<div>{{ form.destination }}</div>
			{{ form.destination.errors }}
		</div>
		<div class="form_field">
			<div class="label">
				{{ form.shortlink.label_tag}}
			</div>
			<div>{{ form.shortlink }}</div>
			{{ form.shortlink.errors }}
		</div>
		<p><button type="submit" value="Giv mig et kort link">Giv mig et kort link</button></p>
		</form>

		{% if request.method == "POST" and not form.destination.errors and not form.shortlink.errors  %}
			<h1>Her er dit link:</h1>
			<p class="sharelink"><a href="{{ sharelink }}">{{ sharelink }}</a></p>
			<button class="copy">Kopier link</button>
		{% endif %} 

	{% endif %}

	{% if error %}
		<h2>Har du tastet forkert?</h2>
		<p><em>Du har prøvet at bruge et kort link. Desværre er det link, du har tastet, ikke registreret. Måske er du kommet til at taste forkert?</em></p>
		<p><a href="{% url 'index' %}">Til forsiden</a>
	{% endif %} 
</div>

{% if request.method == "POST" and not form.destination.errors %}
<script>
function fallbackCopyTextToClipboard(text) {
  var textArea = document.createElement("textarea");
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.focus();
  textArea.select();
  document.execCommand("copy");
  document.body.removeChild(textArea);
}
  
function copyTextToClipboard(text) {
  if (!navigator.clipboard) {
    fallbackCopyTextToClipboard(text);
    return;
  }
  navigator.clipboard.writeText(text);
}

var copy = document.querySelector('.copy');

copy.addEventListener('click', function(event) {
  copyTextToClipboard('{{ sharelink }}');
});
</script>
{% endif %}
{% endspaceless %}{% endblock %}

Er du nået hertil? Se det i praksis på lnk.dk!