Merge branch 'master' into 1451-template-dates

pull/3171/head
KaLieLIKZ 2024-06-03 04:36:52 -04:00
commit f8a85014e6
166 changed files with 8775 additions and 1406 deletions

34
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: CI
on: [push]
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
goVer: [1.16, 1.17, 1.18]
steps:
- name: Set up Go ${{ matrix.goVer }}
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.goVer }}
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get -v -t -d ./...
go get gopkg.in/check.v1
- name: Build
run: go build -v .
- name: Format
run: diff -u <(echo -n) <(gofmt -d .)
- name: Test
run: go test -v ./...

153
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,153 @@
name: Build Gophish Release
on:
release:
types: [created]
jobs:
build:
name: Build Binary
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
arch: ['386', amd64]
# We sometimes use different verbiage for things (e.g. "darwin"
# for the GOOS build flag and "osx" in the actual release ZIP).
# We need to specify those here.
include:
- os: windows-latest
goos: windows
bin: 'gophish.exe'
releaseos: windows
- os: ubuntu-latest
goos: linux
bin: 'gophish'
releaseos: linux
- os: macos-latest
goos: darwin
bin: 'gophish'
releaseos: osx
# Don't build windows-32bit due to missing MinGW dependencies
# Don't build osx-32bit due to eventual drop in Go support
exclude:
- os: windows-latest
arch: '386'
- os: macos-latest
arch: '386'
steps:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.14
- if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y gcc-multilib
- if: matrix.arch == '386'
run: echo "RELEASE=gophish-${{ github.event.release.tag_name }}-${{ matrix.releaseos }}-32bit" >> $GITHUB_ENV
- if: matrix.arch == 'amd64'
run: echo "RELEASE=gophish-${{ github.event.release.tag_name }}-${{ matrix.releaseos }}-64bit" >> $GITHUB_ENV
- if: matrix.os == 'windows-latest'
run: echo "RELEASE=gophish-${{ github.event.release.tag_name }}-${{ matrix.releaseos }}-64bit" | Out-File -FilePath $env:GITHUB_ENV -Append # https://github.com/actions/runner/issues/1636
- uses: actions/checkout@v2
- name: Build ${{ matrix.goos }}/${{ matrix.arch }}
run: go build -o ${{ matrix.bin }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: 1
- name: Upload to artifacts
uses: actions/upload-artifact@v2
with:
name: ${{ env.RELEASE }}
path: ${{ matrix.bin }}
package:
name: Package Assets
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v2
- uses: actions/download-artifact@v2
with:
path: bin
- name: Package Releases
run: |
mkdir releases;
for RELEASE_DIR in bin/*
do
echo "Creating release $RELEASE_DIR"
for BINARY in $RELEASE_DIR/*
do
cp $BINARY .;
zip -r releases/$(basename $RELEASE_DIR).zip \
$(basename ${BINARY}) \
static/js/dist \
static/js/src/vendor/ckeditor \
static/css/dist \
static/images \
static/font \
static/db \
db \
templates \
README.md \
VERSION \
LICENSE \
config.json;
rm $BINARY;
done
done
- name: Upload to artifacts
uses: actions/upload-artifact@v2
with:
name: releases
path: releases/*.zip
upload:
name: Upload to the Release
runs-on: ubuntu-latest
needs: package
steps:
- uses: actions/download-artifact@v2
with:
name: releases
path: releases/
# I would love to use @actions/upload-release-asset, but they don't
# support wildcards in the asset path. Ref #9, #24, and #47
- name: Upload Archives to Release
env:
UPLOAD_URL: ${{ github.event.release.upload_url }}
API_HEADER: "Accept: application/vnd.github.v3+json"
AUTH_HEADER: "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
run: |
UPLOAD_URL=$(echo -n $UPLOAD_URL | sed s/\{.*//g)
for FILE in releases/*
do
echo "Uploading ${FILE}";
curl \
-H "${API_HEADER}" \
-H "${AUTH_HEADER}" \
-H "Content-Type: $(file -b --mime-type ${FILE})" \
--data-binary "@${FILE}" \
"${UPLOAD_URL}?name=$(basename ${FILE})";
done
- name: Generate SHA256 Hashes
env:
API_HEADER: "Accept: application/vnd.github.v3+json"
AUTH_HEADER: "Authorization: token ${{ secrets.GITHUB_TOKEN }}"
RELEASE_URL: ${{ github.event.release.url }}
run: |
HASH_TABLE="| SHA256 Hash | Filename |"
HASH_TABLE="${HASH_TABLE}\n|-----|-----|\n"
for FILE in releases/*
do
FILENAME=$(basename ${FILE})
HASH=$(sha256sum ${FILE} | cut -d ' ' -f 1)
HASH_TABLE="${HASH_TABLE}|${HASH}|${FILENAME}|\n"
done
echo "${HASH_TABLE}"
curl \
-XPATCH \
-H "${API_HEADER}" \
-H "${AUTH_HEADER}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"${HASH_TABLE}\"}" \
"${RELEASE_URL}";

1
.gitignore vendored
View File

@ -13,6 +13,7 @@ node_modules
# Architecture specific extensions/prefixes # Architecture specific extensions/prefixes
*.[568vq] *.[568vq]
[568vq].out [568vq].out
.DS_Store
*.cgo1.go *.cgo1.go
*.cgo2.c *.cgo2.c

View File

@ -1,14 +0,0 @@
language: go
sudo: false
go:
- 1.9
- "1.10"
- 1.11
- 1.12
- tip
install:
- go get -d -v ./... && go build -v ./...
- go get gopkg.in/check.v1
- go get github.com/stretchr/testify

View File

@ -1,30 +1,45 @@
# setup build image # Minify client side assets (JavaScript)
FROM golang:1.11 AS build FROM node:latest AS build-js
# build Gophish binary RUN npm install gulp gulp-cli -g
WORKDIR /build/gophish
WORKDIR /build
COPY . . COPY . .
RUN go get -d -v ./... RUN npm install --only=dev
RUN go build RUN gulp
# setup run image # Build Golang binary
FROM golang:1.15.2 AS build-golang
WORKDIR /go/src/github.com/gophish/gophish
COPY . .
RUN go get -v && go build -v
# Runtime container
FROM debian:stable-slim FROM debian:stable-slim
RUN useradd -m -d /opt/gophish -s /bin/bash app
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -y \ apt-get install --no-install-recommends -y jq libcap2-bin ca-certificates && \
jq && \ apt-get clean && \
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# copy Gophish assets from the build image WORKDIR /opt/gophish
WORKDIR /gophish COPY --from=build-golang /go/src/github.com/gophish/gophish/ ./
COPY --from=build /build/gophish/ /gophish/ COPY --from=build-js /build/static/js/dist/ ./static/js/dist/
RUN chmod +x gophish COPY --from=build-js /build/static/css/dist/ ./static/css/dist/
COPY --from=build-golang /go/src/github.com/gophish/gophish/config.json ./
RUN chown app. config.json
# expose the admin port to the host RUN setcap 'cap_net_bind_service=+ep' /opt/gophish/gophish
USER app
RUN sed -i 's/127.0.0.1/0.0.0.0/g' config.json RUN sed -i 's/127.0.0.1/0.0.0.0/g' config.json
RUN touch config.json.tmp
# expose default ports EXPOSE 3333 8080 8443 80
EXPOSE 80 443 3333
CMD ["./docker/run.sh"] CMD ["./docker/run.sh"]

View File

@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013-2017 Jordan Wright Copyright (c) 2013-2020 Jordan Wright
Permission is hereby granted, free of charge, to any person obtaining a copy of Permission is hereby granted, free of charge, to any person obtaining a copy of
this software ("Gophish Community Edition") and associated documentation files (the "Software"), to deal in this software ("Gophish Community Edition") and associated documentation files (the "Software"), to deal in

View File

@ -3,7 +3,7 @@
Gophish Gophish
======= =======
[![Build Status](https://travis-ci.org/gophish/gophish.svg?branch=master)](https://travis-ci.org/gophish/gophish) [![GoDoc](https://godoc.org/github.com/gophish/gophish?status.svg)](https://godoc.org/github.com/gophish/gophish) ![Build Status](https://github.com/gophish/gophish/workflows/CI/badge.svg) [![GoDoc](https://godoc.org/github.com/gophish/gophish?status.svg)](https://godoc.org/github.com/gophish/gophish)
Gophish: Open-Source Phishing Toolkit Gophish: Open-Source Phishing Toolkit
@ -14,15 +14,21 @@ Gophish: Open-Source Phishing Toolkit
Installation of Gophish is dead-simple - just download and extract the zip containing the [release for your system](https://github.com/gophish/gophish/releases/), and run the binary. Gophish has binary releases for Windows, Mac, and Linux platforms. Installation of Gophish is dead-simple - just download and extract the zip containing the [release for your system](https://github.com/gophish/gophish/releases/), and run the binary. Gophish has binary releases for Windows, Mac, and Linux platforms.
### Building From Source ### Building From Source
**If you are building from source, please note that Gophish requires Go v1.9 or above!** **If you are building from source, please note that Gophish requires Go v1.10 or above!**
To build Gophish from source, simply run ```go get github.com/gophish/gophish``` and ```cd``` into the project source directory. Then, run ```go build```. After this, you should have a binary called ```gophish``` in the current directory. To build Gophish from source, simply run ```git clone https://github.com/gophish/gophish.git``` and ```cd``` into the project source directory. Then, run ```go build```. After this, you should have a binary called ```gophish``` in the current directory.
### Docker ### Docker
You can also use Gophish via an unofficial Docker container [here](https://hub.docker.com/r/matteoggl/gophish/). You can also use Gophish via the official Docker container [here](https://hub.docker.com/r/gophish/gophish/).
### Setup ### Setup
After running the Gophish binary, open an Internet browser to https://localhost:3333 and login with the default username (admin) and password (gophish). After running the Gophish binary, open an Internet browser to https://localhost:3333 and login with the default username and password listed in the log output.
e.g.
```
time="2020-07-29T01:24:08Z" level=info msg="Please login with the username admin and the password 4304d5255378177d"
```
Releases of Gophish prior to v0.10.1 have a default username of `admin` and password of `gophish`.
### Documentation ### Documentation
@ -38,7 +44,7 @@ Gophish - Open-Source Phishing Framework
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2013 - 2018 Jordan Wright Copyright (c) 2013 - 2020 Jordan Wright
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software ("Gophish Community Edition") and associated documentation files (the "Software"), to deal of this software ("Gophish Community Edition") and associated documentation files (the "Software"), to deal

9
SECURITY.md Normal file
View File

@ -0,0 +1,9 @@
# Security Policy
## Reporting a Vulnerability
Thank you for taking the time to find and report a security vulnerability in Gophish!
I'd ask that you please send me an email with the details at hi@getgophish.com rather than posting any details in the public issue tracker.
I'll happily work with you to get the vulnerability resolved as quickly as possible, and will be sure to credit you (if you'd like!) in the release notes for the following release.

View File

@ -1 +1 @@
0.7.1 0.12.1

View File

@ -1,4 +1,4 @@
Tested on Ubuntu 16.04.4. Tested on Ubuntu 20.04 LTS.
Installs Postfix (to listen on localhost only) and the latest Linux gophish binary. setcap is used to allow the gophish binary to listen on privileged ports without running as root. Installs Postfix (to listen on localhost only) and the latest Linux gophish binary. setcap is used to allow the gophish binary to listen on privileged ports without running as root.
@ -17,7 +17,7 @@ ansible-playbook site.yml -i hosts -u root --private-key=private.key
ansible-playbook site.yml -i hosts -u root --ask-pass ansible-playbook site.yml -i hosts -u root --ask-pass
# Log in as non-root user with SSH key (if root login has been disabled) # Log in as non-root user with SSH key (if root login has been disabled)
ansible-playbook site.yml -i hosts --private-key=private.key -u user --become --ask-sudo-pass ansible-playbook site.yml -i hosts --private-key=private.key -u user --become --ask-become-pass
# Logging in as non-root user without SSH keys # Logging in as non-root user without SSH keys
ansible-playbook site.yml -i hosts -u ubuntu --ask-pass --become --ask-sudo-pass ansible-playbook site.yml -i hosts -u ubuntu --ask-pass --become --ask-become-pass

View File

@ -2,16 +2,21 @@
"admin_server": { "admin_server": {
"listen_url": "127.0.0.1:3333", "listen_url": "127.0.0.1:3333",
"use_tls": true, "use_tls": true,
"cert_path" : "gophish_admin.crt", "cert_path": "/etc/ssl/crt/gophish.crt",
"key_path" : "gophish_admin.key" "key_path": "/etc/ssl/private/gophish.pem"
}, },
"phish_server": { "phish_server": {
"listen_url" : "0.0.0.0:80", "listen_url": "127.0.0.1:8080",
"use_tls" : false, "use_tls": true,
"cert_path" : "example.crt", "cert_path": "/etc/ssl/crt/gophish.crt",
"key_path": "example.key" "key_path": "/etc/ssl/private/gophish.pem"
}, },
"db_name": "sqlite3", "db_name": "sqlite3",
"db_path": "gophish.db", "db_path": "gophish.db",
"migrations_prefix" : "db/db_" "migrations_prefix": "db/db_",
"contact_address": "",
"logging": {
"filename": "gophish.log",
"level": ""
}
} }

View File

@ -2,22 +2,27 @@
hostname: hostname:
name: "{{ hostname }}" name: "{{ hostname }}"
- name: Ensure ufw is installed on the machine
package:
name: ufw
state: present
- name: Allow TCP 22 for SSH. - name: Allow TCP 22 for SSH.
ufw: ufw:
rule: allow rule: allow
port: 22 port: "22"
proto: tcp proto: tcp
- name: Allow TCP 80 for Gophish. - name: Allow TCP 80 for Gophish.
ufw: ufw:
rule: allow rule: allow
port: 80 port: "80"
proto: tcp proto: tcp
- name: Allow TCP 443 for Gophish. - name: Allow TCP 443 for Gophish.
ufw: ufw:
rule: allow rule: allow
port: 443 port: "443"
proto: tcp proto: tcp
- name: Enable ufw. - name: Enable ufw.
@ -34,11 +39,55 @@
apt: apt:
upgrade: safe upgrade: safe
- name: Ensure /etc/ssl/csr folder exists
file:
path: /etc/ssl/csr
state: directory
mode: "0755"
- name: Ensure /etc/ssl/private folder exists
file:
path: /etc/ssl/private
state: directory
mode: "0755"
- name: Ensure /etc/ssl/crt folder exists
file:
path: /etc/ssl/crt
state: directory
mode: "0755"
- name: Install specified packages. - name: Install specified packages.
apt: apt:
pkg: "{{ item }}" pkg: "{{ install_packages }}"
state: latest state: latest
with_items: "{{ install_packages }}"
- name: adding existing user '{{ gophish_user }}' to group ssl-cert
user:
name: "{{ gophish_user }}"
groups: ssl-cert
append: yes
- name: Ensure the cryptography Python package is installed
pip:
name: cryptography
- name: Generate an OpenSSL private key with the default values (4096 bits, RSA)
openssl_privatekey:
path: "{{ gophish_ssl_cert_path }}"
- name: Generate an OpenSSL Certificate Signing Request
openssl_csr:
path: "{{ gophish_csr_path }}"
privatekey_path: "{{ gophish_ssl_cert_path }}"
common_name: "{{ gophish_domain }}"
- name: Generate a Self Signed OpenSSL certificate
openssl_certificate:
path: "{{ gophish_crt_path }}"
privatekey_path: "{{ gophish_ssl_cert_path }}"
csr_path: "{{ gophish_csr_path }}"
provider: selfsigned
- name: Update postfix main.cf configuration file. - name: Update postfix main.cf configuration file.
template: template:
@ -60,18 +109,38 @@
state: started state: started
enabled: yes enabled: yes
- name: get latest release info
uri:
url: "https://api.github.com/repos/gophish/gophish/releases/latest"
return_content: true
register: latest_json_reponse
- name: Download latest Gophish .zip file. - name: Download latest Gophish .zip file.
get_url: get_url:
validate_certs: True validate_certs: True
url: https://getgophish.com/releases/latest/linux/64 url: "https://github.com/gophish/gophish/releases/download/{{ latest_json_reponse.json.tag_name }}/gophish-{{ latest_json_reponse.json.tag_name }}-linux-64bit.zip"
dest: "/home/{{ gophish_user }}/gophish.zip" dest: "/home/{{ gophish_user }}/gophish.zip"
mode: 0755 mode: 0755
owner: "{{ gophish_user }}" owner: "{{ gophish_user }}"
group: "{{ gophish_user }}" group: "{{ gophish_user }}"
- name: Ensure gophish user has permission for CRT file.
file:
path: "{{ gophish_crt_path }}"
mode: 0755
owner: "{{ gophish_user }}"
group: "{{ gophish_user }}"
- name: Ensure gophish user has permission for SSL certificate.
file:
path: "{{ gophish_ssl_cert_path }}"
mode: 0755
owner: "{{ gophish_user }}"
group: "{{ gophish_user }}"
- name: Create directory for gophish. - name: Create directory for gophish.
file: file:
path: "/home/{{ gophish_user }}/gophish" path: "/home/{{ gophish_user }}/gophish_deploy"
state: directory state: directory
mode: 0755 mode: 0755
owner: "{{ gophish_user }}" owner: "{{ gophish_user }}"
@ -80,29 +149,78 @@
- name: Unzip gophish file. - name: Unzip gophish file.
unarchive: unarchive:
src: "/home/{{ gophish_user }}/gophish.zip" src: "/home/{{ gophish_user }}/gophish.zip"
dest: "/home/{{ gophish_user }}/gophish" dest: "/home/{{ gophish_user }}/gophish_deploy"
remote_src: True # File is on target server and not locally. remote_src: True # File is on target server and not locally.
owner: "{{ gophish_user }}" owner: "{{ gophish_user }}"
group: "{{ gophish_user }}" group: "{{ gophish_user }}"
- name: Change ownership of Gophish folder and files. - name: Change ownership of Gophish folder and files.
file: file:
path: /home/{{ gophish_user }}/gophish path: /home/{{ gophish_user }}/gophish_deploy
owner: "{{ gophish_user }}" owner: "{{ gophish_user }}"
group: "{{ gophish_user }}" group: "{{ gophish_user }}"
recurse: True recurse: True
- name: Allow gophish binary to bind to privileged ports using setcap. - name: Ensure gophish binary is executable
shell: setcap CAP_NET_BIND_SERVICE=+eip /home/{{ gophish_user }}/gophish/gophish file:
path: /home/{{ gophish_user }}/gophish_deploy/gophish
mode: 744
- name: Ensure gophish binary is allowed to bind to privileged ports using setcap
capabilities:
path: /home/{{ gophish_user }}/gophish_deploy/gophish
capability: cap_net_bind_service+eip
state: present
- name: Copy config.json file. - name: Copy config.json file.
copy: copy:
src: files/config.json src: files/config.json
dest: "/home/{{ gophish_user }}/gophish/config.json" dest: "/home/{{ gophish_user }}/gophish_deploy/config.json"
owner: "{{ gophish_user }}" owner: "{{ gophish_user }}"
group: "{{ gophish_user }}" group: "{{ gophish_user }}"
mode: 0644 mode: 0644
- name: Ensure gophish service file is properly set
template:
src: gophish.service.j2
dest: /etc/systemd/system/gophish.service
mode: 644
- name: Ensure systemd to reread configs
systemd:
daemon_reload: yes
- name: Ensure gophish is properly started
service:
name: gophish.service
state: started
enabled: yes
- name: Ensure nginx is installed
package:
name: nginx
state: present
- name: Ensure nginx service file is properly set
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: 644
- name: Ensure nginx service is restarted
service:
name: nginx
state: reloaded
enabled: yes
- name: get Gophish log file which contain initial password
command: cat /home/{{ gophish_user }}/gophish_deploy/gophish.log
register: gophish_log
- name: display log file
debug:
msg: "{{ gophish_log }}"
- name: Reboot the box in 1 minute. - name: Reboot the box in 1 minute.
command: shutdown -r 1 command: shutdown -r 1
when: reboot_box when: reboot_box

View File

@ -0,0 +1,11 @@
[Unit]
Description=gophish
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/{{ gophish_user }}/gophish_deploy/
ExecStart="/home/{{ gophish_user }}/gophish_deploy/gophish"
User={{ gophish_user }}
PIDFile="/home/{{ gophish_user }}/gophish_deploy/gophish.pid"
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,26 @@
events {
worker_connections 4096;
}
http {
server {
listen 80;
server_name {{gophish_domain}};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
ssl_certificate {{ gophish_crt_path }};
ssl_certificate_key {{ gophish_ssl_cert_path }};
server_name {{gophish_domain}};
location / {
proxy_pass https://127.0.0.1:8080;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
}

View File

@ -3,11 +3,17 @@ enable_ufw_firewall: true
install_packages: install_packages:
- postfix - postfix
- unzip - unzip
- libcap2-bin
- python-is-python3
- python3-pip
hostname: gophish hostname: gophish
gophish_user: ubuntu gophish_user: ubuntu
postfix_hostname: gophish postfix_hostname: gophish
postfix_inet_interfaces: 127.0.0.1 postfix_inet_interfaces: 127.0.0.1
gophish_domain: gophish.local
gophish_ssl_cert_path: /etc/ssl/private/gophish.pem
gophish_csr_path: /etc/ssl/csr/gophish.csr
gophish_crt_path: /etc/ssl/crt/gophish.crt
# Required if changing /etc/hostname to something different. # Required if changing /etc/hostname to something different.
reboot_box: true reboot_box: true

View File

@ -1,69 +1,103 @@
package auth package auth
import ( import (
"crypto/rand"
"errors" "errors"
"net/http" "fmt"
"io"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// MinPasswordLength is the minimum number of characters required in a password
const MinPasswordLength = 8
// APIKeyLength is the length of Gophish API keys
const APIKeyLength = 32
// ErrInvalidPassword is thrown when a user provides an incorrect password. // ErrInvalidPassword is thrown when a user provides an incorrect password.
var ErrInvalidPassword = errors.New("Invalid Password") var ErrInvalidPassword = errors.New("Invalid Password")
// ErrPasswordMismatch is thrown when a user provides a blank password to the register // ErrPasswordMismatch is thrown when a user provides a mismatching password
// or change password functions // and confirmation password.
var ErrPasswordMismatch = errors.New("Password cannot be blank") var ErrPasswordMismatch = errors.New("Passwords do not match")
// ErrReusedPassword is thrown when a user attempts to change their password to
// the existing password
var ErrReusedPassword = errors.New("Cannot reuse existing password")
// ErrEmptyPassword is thrown when a user provides a blank password to the register // ErrEmptyPassword is thrown when a user provides a blank password to the register
// or change password functions // or change password functions
var ErrEmptyPassword = errors.New("No password provided") var ErrEmptyPassword = errors.New("No password provided")
// Login attempts to login the user given a request. // ErrPasswordTooShort is thrown when a user provides a password that is less
func Login(r *http.Request) (bool, models.User, error) { // than MinPasswordLength
username, password := r.FormValue("username"), r.FormValue("password") var ErrPasswordTooShort = fmt.Errorf("Password must be at least %d characters", MinPasswordLength)
u, err := models.GetUserByUsername(username)
if err != nil { // GenerateSecureKey returns the hex representation of key generated from n
return false, models.User{}, err // random bytes
} func GenerateSecureKey(n int) string {
//If we've made it here, we should have a valid user stored in u k := make([]byte, n)
//Let's check the password io.ReadFull(rand.Reader, k)
err = bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)) return fmt.Sprintf("%x", k)
if err != nil {
return false, models.User{}, ErrInvalidPassword
}
return true, u, nil
} }
// ChangePassword verifies the current password provided in the request and, // GeneratePasswordHash returns the bcrypt hash for the provided password using
// if it's valid, changes the password for the authenticated user. // the default bcrypt cost.
func ChangePassword(r *http.Request) error { func GeneratePasswordHash(password string) (string, error) {
u := ctx.Get(r, "user").(models.User) h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
currentPw := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_new_password")
// Check the current password
err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(currentPw))
if err != nil { if err != nil {
return ErrInvalidPassword return "", err
} }
// Check that the new password isn't blank return string(h), nil
if newPassword == "" { }
// CheckPasswordPolicy ensures the provided password is valid according to our
// password policy.
//
// The current password policy is simply a minimum of 8 characters, though this
// may change in the future (see #1538).
func CheckPasswordPolicy(password string) error {
switch {
// Admittedly, empty passwords are a subset of too short passwords, but it
// helps to provide a more specific error message
case password == "":
return ErrEmptyPassword return ErrEmptyPassword
} case len(password) < MinPasswordLength:
// Check that new passwords match return ErrPasswordTooShort
if newPassword != confirmPassword {
return ErrPasswordMismatch
}
// Generate the new hash
h, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Hash = string(h)
if err = models.PutUser(&u); err != nil {
return err
} }
return nil return nil
} }
// ValidatePassword validates that the provided password matches the provided
// bcrypt hash.
func ValidatePassword(password string, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
// ValidatePasswordChange validates that the new password matches the
// configured password policy, that the new password and confirmation
// password match.
//
// Note that this assumes the current password has been confirmed by the
// caller.
//
// If all of the provided data is valid, then the hash of the new password is
// returned.
func ValidatePasswordChange(currentHash, newPassword, confirmPassword string) (string, error) {
// Ensure the new password passes our password policy
if err := CheckPasswordPolicy(newPassword); err != nil {
return "", err
}
// Check that new passwords match
if newPassword != confirmPassword {
return "", ErrPasswordMismatch
}
// Make sure that the new password isn't the same as the old one
err := ValidatePassword(newPassword, currentHash)
if err == nil {
return "", ErrReusedPassword
}
// Generate the new hash
return GeneratePasswordHash(newPassword)
}

41
auth/auth_test.go Normal file
View File

@ -0,0 +1,41 @@
package auth
import (
"testing"
)
func TestPasswordPolicy(t *testing.T) {
candidate := "short"
got := CheckPasswordPolicy(candidate)
if got != ErrPasswordTooShort {
t.Fatalf("unexpected error received. expected %v got %v", ErrPasswordTooShort, got)
}
candidate = "valid password"
got = CheckPasswordPolicy(candidate)
if got != nil {
t.Fatalf("unexpected error received. expected %v got %v", nil, got)
}
}
func TestValidatePasswordChange(t *testing.T) {
newPassword := "valid password"
confirmPassword := "invalid"
currentPassword := "current password"
currentHash, err := GeneratePasswordHash(currentPassword)
if err != nil {
t.Fatalf("unexpected error generating password hash: %v", err)
}
_, got := ValidatePasswordChange(currentHash, newPassword, confirmPassword)
if got != ErrPasswordMismatch {
t.Fatalf("unexpected error received. expected %v got %v", ErrPasswordMismatch, got)
}
newPassword = currentPassword
confirmPassword = newPassword
_, got = ValidatePasswordChange(currentHash, newPassword, confirmPassword)
if got != ErrReusedPassword {
t.Fatalf("unexpected error received. expected %v got %v", ErrReusedPassword, got)
}
}

View File

@ -3,7 +3,8 @@
"listen_url": "127.0.0.1:3333", "listen_url": "127.0.0.1:3333",
"use_tls": true, "use_tls": true,
"cert_path": "gophish_admin.crt", "cert_path": "gophish_admin.crt",
"key_path": "gophish_admin.key" "key_path": "gophish_admin.key",
"trusted_origins": []
}, },
"phish_server": { "phish_server": {
"listen_url": "0.0.0.0:80", "listen_url": "0.0.0.0:80",
@ -16,6 +17,7 @@
"migrations_prefix": "db/db_", "migrations_prefix": "db/db_",
"contact_address": "", "contact_address": "",
"logging": { "logging": {
"filename": "" "filename": "",
"level": ""
} }
} }

View File

@ -3,6 +3,8 @@ package config
import ( import (
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
log "github.com/gophish/gophish/logger"
) )
// AdminServer represents the Admin server configuration details // AdminServer represents the Admin server configuration details
@ -11,6 +13,9 @@ type AdminServer struct {
UseTLS bool `json:"use_tls"` UseTLS bool `json:"use_tls"`
CertPath string `json:"cert_path"` CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
CSRFKey string `json:"csrf_key"`
AllowedInternalHosts []string `json:"allowed_internal_hosts"`
TrustedOrigins []string `json:"trusted_origins"`
} }
// PhishServer represents the Phish server configuration details // PhishServer represents the Phish server configuration details
@ -21,21 +26,17 @@ type PhishServer struct {
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
} }
// LoggingConfig represents configuration details for Gophish logging.
type LoggingConfig struct {
Filename string `json:"filename"`
}
// Config represents the configuration information. // Config represents the configuration information.
type Config struct { type Config struct {
AdminConf AdminServer `json:"admin_server"` AdminConf AdminServer `json:"admin_server"`
PhishConf PhishServer `json:"phish_server"` PhishConf PhishServer `json:"phish_server"`
DBName string `json:"db_name"` DBName string `json:"db_name"`
DBPath string `json:"db_path"` DBPath string `json:"db_path"`
DBSSLCaPath string `json:"db_sslca_path"`
MigrationsPath string `json:"migrations_prefix"` MigrationsPath string `json:"migrations_prefix"`
TestFlag bool `json:"test_flag"` TestFlag bool `json:"test_flag"`
ContactAddress string `json:"contact_address"` ContactAddress string `json:"contact_address"`
Logging LoggingConfig `json:"logging"` Logging *log.Config `json:"logging"`
} }
// Version contains the current gophish version // Version contains the current gophish version
@ -56,6 +57,9 @@ func LoadConfig(filepath string) (*Config, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if config.Logging == nil {
config.Logging = &log.Config{}
}
// Choosing the migrations directory based on the database used. // Choosing the migrations directory based on the database used.
config.MigrationsPath = config.MigrationsPath + config.DBName config.MigrationsPath = config.MigrationsPath + config.DBName
// Explicitly set the TestFlag to false to prevent config.json overrides // Explicitly set the TestFlag to false to prevent config.json overrides

View File

@ -4,16 +4,12 @@ import (
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"os" "os"
"reflect"
"testing" "testing"
"github.com/stretchr/testify/suite" log "github.com/gophish/gophish/logger"
) )
type ConfigSuite struct {
suite.Suite
ConfigFile *os.File
}
var validConfig = []byte(`{ var validConfig = []byte(`{
"admin_server": { "admin_server": {
"listen_url": "127.0.0.1:3333", "listen_url": "127.0.0.1:3333",
@ -33,36 +29,50 @@ var validConfig = []byte(`{
"contact_address": "" "contact_address": ""
}`) }`)
func (s *ConfigSuite) SetupTest() { func createTemporaryConfig(t *testing.T) *os.File {
f, err := ioutil.TempFile("", "gophish-config") f, err := ioutil.TempFile("", "gophish-config")
s.Nil(err) if err != nil {
s.ConfigFile = f t.Fatalf("unable to create temporary config: %v", err)
}
return f
} }
func (s *ConfigSuite) TearDownTest() { func removeTemporaryConfig(t *testing.T, f *os.File) {
err := s.ConfigFile.Close() err := f.Close()
s.Nil(err) if err != nil {
t.Fatalf("unable to remove temporary config: %v", err)
}
} }
func (s *ConfigSuite) TestLoadConfig() { func TestLoadConfig(t *testing.T) {
_, err := s.ConfigFile.Write(validConfig) f := createTemporaryConfig(t)
s.Nil(err) defer removeTemporaryConfig(t, f)
_, err := f.Write(validConfig)
if err != nil {
t.Fatalf("error writing config to temporary file: %v", err)
}
// Load the valid config // Load the valid config
conf, err := LoadConfig(s.ConfigFile.Name()) conf, err := LoadConfig(f.Name())
s.Nil(err) if err != nil {
t.Fatalf("error loading config from temporary file: %v", err)
}
expectedConfig := &Config{} expectedConfig := &Config{}
err = json.Unmarshal(validConfig, &expectedConfig) err = json.Unmarshal(validConfig, &expectedConfig)
s.Nil(err) if err != nil {
t.Fatalf("error unmarshaling config: %v", err)
}
expectedConfig.MigrationsPath = expectedConfig.MigrationsPath + expectedConfig.DBName expectedConfig.MigrationsPath = expectedConfig.MigrationsPath + expectedConfig.DBName
expectedConfig.TestFlag = false expectedConfig.TestFlag = false
s.Equal(expectedConfig, conf) expectedConfig.AdminConf.CSRFKey = ""
expectedConfig.Logging = &log.Config{}
if !reflect.DeepEqual(expectedConfig, conf) {
t.Fatalf("invalid config received. expected %#v got %#v", expectedConfig, conf)
}
// Load an invalid config // Load an invalid config
conf, err = LoadConfig("bogusfile") _, err = LoadConfig("bogusfile")
s.NotNil(err) if err == nil {
t.Fatalf("expected error when loading invalid config, but got %v", err)
} }
func TestConfigSuite(t *testing.T) {
suite.Run(t, new(ConfigSuite))
} }

View File

@ -1,3 +1,4 @@
//go:build !go1.7
// +build !go1.7 // +build !go1.7
package context package context

View File

@ -1,3 +1,4 @@
//go:build go1.7
// +build go1.7 // +build go1.7
package context package context
@ -23,6 +24,4 @@ func Set(r *http.Request, key, val interface{}) *http.Request {
} }
// Clear is a null operation, since this is handled automatically in Go > 1.7 // Clear is a null operation, since this is handled automatically in Go > 1.7
func Clear(r *http.Request) { func Clear(r *http.Request) {}
return
}

View File

@ -6,23 +6,20 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"testing" "testing"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/stretchr/testify/suite"
) )
type APISuite struct { type testContext struct {
suite.Suite
apiKey string apiKey string
config *config.Config config *config.Config
apiServer *Server apiServer *Server
admin models.User admin models.User
} }
func (s *APISuite) SetupSuite() { func setupTest(t *testing.T) *testContext {
conf := &config.Config{ conf := &config.Config{
DBName: "sqlite3", DBName: "sqlite3",
DBPath: ":memory:", DBPath: ":memory:",
@ -30,39 +27,22 @@ func (s *APISuite) SetupSuite() {
} }
err := models.Setup(conf) err := models.Setup(conf)
if err != nil { if err != nil {
s.T().Fatalf("Failed creating database: %v", err) t.Fatalf("Failed creating database: %v", err)
} }
s.config = conf ctx := &testContext{}
s.Nil(err) ctx.config = conf
// Get the API key to use for these tests // Get the API key to use for these tests
u, err := models.GetUser(1) u, err := models.GetUser(1)
s.Nil(err) if err != nil {
s.apiKey = u.ApiKey t.Fatalf("error getting admin user: %v", err)
s.admin = u }
// Move our cwd up to the project root for help with resolving ctx.apiKey = u.ApiKey
// static assets ctx.admin = u
err = os.Chdir("../") ctx.apiServer = NewServer()
s.Nil(err) return ctx
s.apiServer = NewServer()
} }
func (s *APISuite) TearDownTest() { func createTestData(t *testing.T) {
campaigns, _ := models.GetCampaigns(1)
for _, campaign := range campaigns {
models.DeleteCampaign(campaign.Id)
}
// Cleanup all users except the original admin
users, _ := models.GetUsers()
for _, user := range users {
if user.Id == 1 {
continue
}
err := models.DeleteUser(user.Id)
s.Nil(err)
}
}
func (s *APISuite) SetupTest() {
// Add a group // Add a group
group := models.Group{Name: "Test Group"} group := models.Group{Name: "Test Group"}
group.Targets = []models.Target{ group.Targets = []models.Target{
@ -73,12 +53,12 @@ func (s *APISuite) SetupTest() {
models.PostGroup(&group) models.PostGroup(&group)
// Add a template // Add a template
t := models.Template{Name: "Test Template"} template := models.Template{Name: "Test Template"}
t.Subject = "Test subject" template.Subject = "Test subject"
t.Text = "Text text" template.Text = "Text text"
t.HTML = "<html>Test</html>" template.HTML = "<html>Test</html>"
t.UserId = 1 template.UserId = 1
models.PostTemplate(&t) models.PostTemplate(&template)
// Add a landing page // Add a landing page
p := models.Page{Name: "Test Page"} p := models.Page{Name: "Test Page"}
@ -97,7 +77,7 @@ func (s *APISuite) SetupTest() {
// Set the status such that no emails are attempted // Set the status such that no emails are attempted
c := models.Campaign{Name: "Test campaign"} c := models.Campaign{Name: "Test campaign"}
c.UserId = 1 c.UserId = 1
c.Template = t c.Template = template
c.Page = p c.Page = p
c.SMTP = smtp c.SMTP = smtp
c.Groups = []models.Group{group} c.Groups = []models.Group{group}
@ -105,12 +85,13 @@ func (s *APISuite) SetupTest() {
c.UpdateStatus(models.CampaignEmailsSent) c.UpdateStatus(models.CampaignEmailsSent)
} }
func (s *APISuite) TestSiteImportBaseHref() { func TestSiteImportBaseHref(t *testing.T) {
ctx := setupTest(t)
h := "<html><head></head><body><img src=\"/test.png\"/></body></html>" h := "<html><head></head><body><img src=\"/test.png\"/></body></html>"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, h) fmt.Fprintln(w, h)
})) }))
hr := fmt.Sprintf("<html><head><base href=\"%s\"/></head><body><img src=\"/test.png\"/>\n</body></html>", ts.URL) expected := fmt.Sprintf("<html><head><base href=\"%s\"/></head><body><img src=\"/test.png\"/>\n</body></html>", ts.URL)
defer ts.Close() defer ts.Close()
req := httptest.NewRequest(http.MethodPost, "/api/import/site", req := httptest.NewRequest(http.MethodPost, "/api/import/site",
bytes.NewBuffer([]byte(fmt.Sprintf(` bytes.NewBuffer([]byte(fmt.Sprintf(`
@ -121,13 +102,13 @@ func (s *APISuite) TestSiteImportBaseHref() {
`, ts.URL)))) `, ts.URL))))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
s.apiServer.ImportSite(response, req) ctx.apiServer.ImportSite(response, req)
cs := cloneResponse{} cs := cloneResponse{}
err := json.NewDecoder(response.Body).Decode(&cs) err := json.NewDecoder(response.Body).Decode(&cs)
s.Nil(err) if err != nil {
s.Equal(cs.HTML, hr) t.Fatalf("error decoding response: %v", err)
}
if cs.HTML != expected {
t.Fatalf("unexpected response received. expected %s got %s", expected, cs.HTML)
} }
func TestAPISuite(t *testing.T) {
suite.Run(t, new(APISuite))
} }

View File

@ -87,6 +87,11 @@ func (as *Server) Group(w http.ResponseWriter, r *http.Request) {
// Change this to get from URL and uid (don't bother with id in r.Body) // Change this to get from URL and uid (don't bother with id in r.Body)
g = models.Group{} g = models.Group{}
err = json.NewDecoder(r.Body).Decode(&g) err = json.NewDecoder(r.Body).Decode(&g)
if err != nil {
log.Errorf("error decoding group: %v", err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
if g.Id != id { if g.Id != id {
JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError)
return return

62
controllers/api/imap.go Normal file
View File

@ -0,0 +1,62 @@
package api
import (
"encoding/json"
"net/http"
"time"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/imap"
"github.com/gophish/gophish/models"
)
// IMAPServerValidate handles requests for the /api/imapserver/validate endpoint
func (as *Server) IMAPServerValidate(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
JSONResponse(w, models.Response{Success: false, Message: "Only POSTs allowed"}, http.StatusBadRequest)
case r.Method == "POST":
im := models.IMAP{}
err := json.NewDecoder(r.Body).Decode(&im)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid request"}, http.StatusBadRequest)
return
}
err = imap.Validate(&im)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusOK)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Successful login."}, http.StatusCreated)
}
}
// IMAPServer handles requests for the /api/imapserver/ endpoint
func (as *Server) IMAPServer(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
ss, err := models.GetIMAP(ctx.Get(r, "user_id").(int64))
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, ss, http.StatusOK)
// POST: Update database
case r.Method == "POST":
im := models.IMAP{}
err := json.NewDecoder(r.Body).Decode(&im)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid data. Please check your IMAP settings."}, http.StatusBadRequest)
return
}
im.ModifiedDate = time.Now().UTC()
im.UserId = ctx.Get(r, "user_id").(int64)
err = models.PostIMAP(&im, ctx.Get(r, "user_id").(int64))
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Successfully saved IMAP settings."}, http.StatusCreated)
}
}

View File

@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/gophish/gophish/dialer"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util" "github.com/gophish/gophish/util"
@ -46,7 +47,6 @@ func (as *Server) ImportGroup(w http.ResponseWriter, r *http.Request) {
return return
} }
JSONResponse(w, ts, http.StatusOK) JSONResponse(w, ts, http.StatusOK)
return
} }
// ImportEmail allows for the importing of email. // ImportEmail allows for the importing of email.
@ -94,7 +94,6 @@ func (as *Server) ImportEmail(w http.ResponseWriter, r *http.Request) {
HTML: string(e.HTML), HTML: string(e.HTML),
} }
JSONResponse(w, er, http.StatusOK) JSONResponse(w, er, http.StatusOK)
return
} }
// ImportSite allows for the importing of HTML from a website // ImportSite allows for the importing of HTML from a website
@ -115,7 +114,9 @@ func (as *Server) ImportSite(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
restrictedDialer := dialer.Dialer()
tr := &http.Transport{ tr := &http.Transport{
DialContext: restrictedDialer.DialContext,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true,
}, },
@ -153,5 +154,4 @@ func (as *Server) ImportSite(w http.ResponseWriter, r *http.Request) {
} }
cs := cloneResponse{HTML: h} cs := cloneResponse{HTML: h}
JSONResponse(w, cs, http.StatusOK) JSONResponse(w, cs, http.StatusOK)
return
} }

View File

@ -0,0 +1,84 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gophish/gophish/dialer"
"github.com/gophish/gophish/models"
)
func makeImportRequest(ctx *testContext, allowedHosts []string, url string) *httptest.ResponseRecorder {
orig := dialer.DefaultDialer.AllowedHosts()
dialer.SetAllowedHosts(allowedHosts)
req := httptest.NewRequest(http.MethodPost, "/api/import/site",
bytes.NewBuffer([]byte(fmt.Sprintf(`
{
"url" : "%s"
}
`, url))))
req.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
ctx.apiServer.ImportSite(response, req)
dialer.SetAllowedHosts(orig)
return response
}
func TestDefaultDeniedImport(t *testing.T) {
ctx := setupTest(t)
metadataURL := "http://169.254.169.254/latest/meta-data/"
response := makeImportRequest(ctx, []string{}, metadataURL)
expectedCode := http.StatusBadRequest
if response.Code != expectedCode {
t.Fatalf("incorrect status code received. expected %d got %d", expectedCode, response.Code)
}
got := &models.Response{}
err := json.NewDecoder(response.Body).Decode(got)
if err != nil {
t.Fatalf("error decoding body: %v", err)
}
if !strings.Contains(got.Message, "upstream connection denied") {
t.Fatalf("incorrect response error provided: %s", got.Message)
}
}
func TestDefaultAllowedImport(t *testing.T) {
ctx := setupTest(t)
h := "<html><head></head><body><img src=\"/test.png\"/></body></html>"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, h)
}))
defer ts.Close()
response := makeImportRequest(ctx, []string{}, ts.URL)
expectedCode := http.StatusOK
if response.Code != expectedCode {
t.Fatalf("incorrect status code received. expected %d got %d", expectedCode, response.Code)
}
}
func TestCustomDeniedImport(t *testing.T) {
ctx := setupTest(t)
h := "<html><head></head><body><img src=\"/test.png\"/></body></html>"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, h)
}))
defer ts.Close()
response := makeImportRequest(ctx, []string{"192.168.1.1"}, ts.URL)
expectedCode := http.StatusBadRequest
if response.Code != expectedCode {
t.Fatalf("incorrect status code received. expected %d got %d", expectedCode, response.Code)
}
got := &models.Response{}
err := json.NewDecoder(response.Body).Decode(got)
if err != nil {
t.Fatalf("error decoding body: %v", err)
}
if !strings.Contains(got.Message, "upstream connection denied") {
t.Fatalf("incorrect response error provided: %s", got.Message)
}
}

View File

@ -3,9 +3,9 @@ package api
import ( import (
"net/http" "net/http"
"github.com/gophish/gophish/auth"
ctx "github.com/gophish/gophish/context" ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
) )
// Reset (/api/reset) resets the currently authenticated user's API key // Reset (/api/reset) resets the currently authenticated user's API key
@ -13,7 +13,7 @@ func (as *Server) Reset(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == "POST": case r.Method == "POST":
u := ctx.Get(r, "user").(models.User) u := ctx.Get(r, "user").(models.User)
u.ApiKey = util.GenerateSecureKey() u.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
err := models.PutUser(&u) err := models.PutUser(&u)
if err != nil { if err != nil {
http.Error(w, "Error setting API Key", http.StatusInternalServerError) http.Error(w, "Error setting API Key", http.StatusInternalServerError)

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
mid "github.com/gophish/gophish/middleware" mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/middleware/ratelimit"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/worker" "github.com/gophish/gophish/worker"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -19,14 +20,17 @@ type ServerOption func(*Server)
type Server struct { type Server struct {
handler http.Handler handler http.Handler
worker worker.Worker worker worker.Worker
limiter *ratelimit.PostLimiter
} }
// NewServer returns a new instance of the API handler with the provided // NewServer returns a new instance of the API handler with the provided
// options applied. // options applied.
func NewServer(options ...ServerOption) *Server { func NewServer(options ...ServerOption) *Server {
defaultWorker, _ := worker.New() defaultWorker, _ := worker.New()
defaultLimiter := ratelimit.NewPostLimiter()
as := &Server{ as := &Server{
worker: defaultWorker, worker: defaultWorker,
limiter: defaultLimiter,
} }
for _, opt := range options { for _, opt := range options {
opt(as) opt(as)
@ -42,12 +46,20 @@ func WithWorker(w worker.Worker) ServerOption {
} }
} }
func WithLimiter(limiter *ratelimit.PostLimiter) ServerOption {
return func(as *Server) {
as.limiter = limiter
}
}
func (as *Server) registerRoutes() { func (as *Server) registerRoutes() {
root := mux.NewRouter() root := mux.NewRouter()
root = root.StrictSlash(true) root = root.StrictSlash(true)
router := root.PathPrefix("/api/").Subrouter() router := root.PathPrefix("/api/").Subrouter()
router.Use(mid.RequireAPIKey) router.Use(mid.RequireAPIKey)
router.Use(mid.EnforceViewOnly) router.Use(mid.EnforceViewOnly)
router.HandleFunc("/imap/", as.IMAPServer)
router.HandleFunc("/imap/validate", as.IMAPServerValidate)
router.HandleFunc("/reset", as.Reset) router.HandleFunc("/reset", as.Reset)
router.HandleFunc("/campaigns/", as.Campaigns) router.HandleFunc("/campaigns/", as.Campaigns)
router.HandleFunc("/campaigns/summary", as.CampaignsSummary) router.HandleFunc("/campaigns/summary", as.CampaignsSummary)
@ -71,6 +83,9 @@ func (as *Server) registerRoutes() {
router.HandleFunc("/import/group", as.ImportGroup) router.HandleFunc("/import/group", as.ImportGroup)
router.HandleFunc("/import/email", as.ImportEmail) router.HandleFunc("/import/email", as.ImportEmail)
router.HandleFunc("/import/site", as.ImportSite) router.HandleFunc("/import/site", as.ImportSite)
router.HandleFunc("/webhooks/", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/webhooks/{id:[0-9]+}/validate", mid.Use(as.ValidateWebhook, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/webhooks/{id:[0-9]+}", mid.Use(as.Webhook, mid.RequirePermission(models.PermissionModifySystem)))
as.handler = router as.handler = router
} }

View File

@ -6,18 +6,14 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gophish/gophish/auth"
ctx "github.com/gophish/gophish/context" ctx "github.com/gophish/gophish/context"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
) )
// ErrEmptyPassword is thrown when a user provides a blank password to the register
// or change password functions
var ErrEmptyPassword = errors.New("No password provided")
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken. // ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
var ErrUsernameTaken = errors.New("Username already taken") var ErrUsernameTaken = errors.New("Username already taken")
@ -36,6 +32,8 @@ type userRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Role string `json:"role"` Role string `json:"role"`
PasswordChangeRequired bool `json:"password_change_required"`
AccountLocked bool `json:"account_locked"`
} }
func (ur *userRequest) Validate(existingUser *models.User) error { func (ur *userRequest) Validate(existingUser *models.User) error {
@ -89,11 +87,12 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
if ur.Password == "" { err = auth.CheckPasswordPolicy(ur.Password)
JSONResponse(w, models.Response{Success: false, Message: ErrEmptyPassword.Error()}, http.StatusBadRequest) if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
hash, err := util.NewHash(ur.Password) hash, err := auth.GeneratePasswordHash(ur.Password)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return return
@ -106,9 +105,10 @@ func (as *Server) Users(w http.ResponseWriter, r *http.Request) {
user := models.User{ user := models.User{
Username: ur.Username, Username: ur.Username,
Hash: hash, Hash: hash,
ApiKey: util.GenerateSecureKey(), ApiKey: auth.GenerateSecureKey(auth.APIKeyLength),
Role: role, Role: role,
RoleID: role.ID, RoleID: role.ID,
PasswordChangeRequired: ur.PasswordChangeRequired,
} }
err = models.PutUser(&user) err = models.PutUser(&user)
if err != nil { if err != nil {
@ -195,19 +195,27 @@ func (as *Server) User(w http.ResponseWriter, r *http.Request) {
// We don't force the password to be provided, since it may be an admin // We don't force the password to be provided, since it may be an admin
// managing the user's account, and making a simple change like // managing the user's account, and making a simple change like
// updating the username or role. However, if it _is_ provided, we'll // updating the username or role. However, if it _is_ provided, we'll
// update the stored hash. // update the stored hash after validating the new password meets our
// password policy.
// //
// Note that we don't force the current password to be provided. The // Note that we don't force the current password to be provided. The
// assumption here is that the API key is a proper bearer token proving // assumption here is that the API key is a proper bearer token proving
// authenticated access to the account. // authenticated access to the account.
existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
if ur.Password != "" { if ur.Password != "" {
hash, err := util.NewHash(ur.Password) err = auth.CheckPasswordPolicy(ur.Password)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
hash, err := auth.GeneratePasswordHash(ur.Password)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return return
} }
existingUser.Hash = hash existingUser.Hash = hash
} }
existingUser.AccountLocked = ur.AccountLocked
err = models.PutUser(&existingUser) err = models.PutUser(&existingUser)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -13,9 +14,11 @@ import (
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
) )
func (s *APISuite) createUnpriviledgedUser(slug string) *models.User { func createUnpriviledgedUser(t *testing.T, slug string) *models.User {
role, err := models.GetRoleBySlug(slug) role, err := models.GetRoleBySlug(slug)
s.Nil(err) if err != nil {
t.Fatalf("error getting role by slug: %v", err)
}
unauthorizedUser := &models.User{ unauthorizedUser := &models.User{
Username: "foo", Username: "foo",
Hash: "bar", Hash: "bar",
@ -24,56 +27,82 @@ func (s *APISuite) createUnpriviledgedUser(slug string) *models.User {
RoleID: role.ID, RoleID: role.ID,
} }
err = models.PutUser(unauthorizedUser) err = models.PutUser(unauthorizedUser)
s.Nil(err) if err != nil {
t.Fatalf("error saving unpriviledged user: %v", err)
}
return unauthorizedUser return unauthorizedUser
} }
func (s *APISuite) TestGetUsers() { func TestGetUsers(t *testing.T) {
testCtx := setupTest(t)
r := httptest.NewRequest(http.MethodGet, "/api/users", nil) r := httptest.NewRequest(http.MethodGet, "/api/users", nil)
r = ctx.Set(r, "user", s.admin) r = ctx.Set(r, "user", testCtx.admin)
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.Users(w, r) testCtx.apiServer.Users(w, r)
s.Equal(w.Code, http.StatusOK) expected := http.StatusOK
if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
got := []models.User{} got := []models.User{}
err := json.NewDecoder(w.Body).Decode(&got) err := json.NewDecoder(w.Body).Decode(&got)
s.Nil(err) if err != nil {
t.Fatalf("error decoding users data: %v", err)
// We only expect one user
s.Equal(1, len(got))
// And it should be the admin user
s.Equal(s.admin.Id, got[0].Id)
} }
func (s *APISuite) TestCreateUser() { // We only expect one user
expectedUsers := 1
if len(got) != expectedUsers {
t.Fatalf("unexpected number of users returned. expected %d got %d", expectedUsers, len(got))
}
// And it should be the admin user
if testCtx.admin.Id != got[0].Id {
t.Fatalf("unexpected user received. expected %d got %d", testCtx.admin.Id, got[0].Id)
}
}
func TestCreateUser(t *testing.T) {
testCtx := setupTest(t)
payload := &userRequest{ payload := &userRequest{
Username: "foo", Username: "foo",
Password: "bar", Password: "validpassword",
Role: models.RoleUser, Role: models.RoleUser,
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
s.Nil(err) if err != nil {
t.Fatalf("error marshaling userRequest payload: %v", err)
}
r := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBuffer(body)) r := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBuffer(body))
r.Header.Set("Content-Type", "application/json") r.Header.Set("Content-Type", "application/json")
r = ctx.Set(r, "user", s.admin) r = ctx.Set(r, "user", testCtx.admin)
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.Users(w, r) testCtx.apiServer.Users(w, r)
s.Equal(w.Code, http.StatusOK) expected := http.StatusOK
if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
got := &models.User{} got := &models.User{}
err = json.NewDecoder(w.Body).Decode(got) err = json.NewDecoder(w.Body).Decode(got)
s.Nil(err) if err != nil {
s.Equal(got.Username, payload.Username) t.Fatalf("error decoding user payload: %v", err)
s.Equal(got.Role.Slug, payload.Role) }
if got.Username != payload.Username {
t.Fatalf("unexpected username received. expected %s got %s", payload.Username, got.Username)
}
if got.Role.Slug != payload.Role {
t.Fatalf("unexpected role received. expected %s got %s", payload.Role, got.Role.Slug)
}
} }
// TestModifyUser tests that a user with the appropriate access is able to // TestModifyUser tests that a user with the appropriate access is able to
// modify their username and password. // modify their username and password.
func (s *APISuite) TestModifyUser() { func TestModifyUser(t *testing.T) {
unpriviledgedUser := s.createUnpriviledgedUser(models.RoleUser) testCtx := setupTest(t)
unpriviledgedUser := createUnpriviledgedUser(t, models.RoleUser)
newPassword := "new-password" newPassword := "new-password"
newUsername := "new-username" newUsername := "new-username"
payload := userRequest{ payload := userRequest{
@ -82,33 +111,48 @@ func (s *APISuite) TestModifyUser() {
Role: unpriviledgedUser.Role.Slug, Role: unpriviledgedUser.Role.Slug,
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
s.Nil(err) if err != nil {
t.Fatalf("error marshaling userRequest payload: %v", err)
}
url := fmt.Sprintf("/api/users/%d", unpriviledgedUser.Id) url := fmt.Sprintf("/api/users/%d", unpriviledgedUser.Id)
r := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(body)) r := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(body))
r.Header.Set("Content-Type", "application/json") r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unpriviledgedUser.ApiKey)) r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unpriviledgedUser.ApiKey))
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r) testCtx.apiServer.ServeHTTP(w, r)
response := &models.User{} response := &models.User{}
err = json.NewDecoder(w.Body).Decode(response) err = json.NewDecoder(w.Body).Decode(response)
s.Nil(err) if err != nil {
s.Equal(w.Code, http.StatusOK) t.Fatalf("error decoding user payload: %v", err)
s.Equal(response.Username, newUsername) }
expected := http.StatusOK
if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
if response.Username != newUsername {
t.Fatalf("unexpected username received. expected %s got %s", newUsername, response.Username)
}
got, err := models.GetUser(unpriviledgedUser.Id) got, err := models.GetUser(unpriviledgedUser.Id)
s.Nil(err) if err != nil {
s.Equal(response.Username, got.Username) t.Fatalf("error getting unpriviledged user: %v", err)
s.Equal(newUsername, got.Username) }
if response.Username != got.Username {
t.Fatalf("unexpected username received. expected %s got %s", response.Username, got.Username)
}
err = bcrypt.CompareHashAndPassword([]byte(got.Hash), []byte(newPassword)) err = bcrypt.CompareHashAndPassword([]byte(got.Hash), []byte(newPassword))
s.Nil(err) if err != nil {
t.Fatalf("incorrect hash received for created user. expected %s got %s", []byte(newPassword), []byte(got.Hash))
}
} }
// TestUnauthorizedListUsers ensures that users without the ModifySystem // TestUnauthorizedListUsers ensures that users without the ModifySystem
// permission are unable to list the users registered in Gophish. // permission are unable to list the users registered in Gophish.
func (s *APISuite) TestUnauthorizedListUsers() { func TestUnauthorizedListUsers(t *testing.T) {
testCtx := setupTest(t)
// First, let's create a standard user which doesn't // First, let's create a standard user which doesn't
// have ModifySystem permissions. // have ModifySystem permissions.
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser) unauthorizedUser := createUnpriviledgedUser(t, models.RoleUser)
// We'll try to make a request to the various users API endpoints to // We'll try to make a request to the various users API endpoints to
// ensure that they fail. Previously, we could hit the handlers directly // ensure that they fail. Previously, we could hit the handlers directly
// but we need to go through the router for this test to ensure the // but we need to go through the router for this test to ensure the
@ -117,72 +161,99 @@ func (s *APISuite) TestUnauthorizedListUsers() {
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey)) r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r) testCtx.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusForbidden) expected := http.StatusForbidden
if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
} }
// TestUnauthorizedModifyUsers verifies that users without ModifySystem // TestUnauthorizedModifyUsers verifies that users without ModifySystem
// permission (a "standard" user) can only get or modify their own information. // permission (a "standard" user) can only get or modify their own information.
func (s *APISuite) TestUnauthorizedGetUser() { func TestUnauthorizedGetUser(t *testing.T) {
testCtx := setupTest(t)
// First, we'll make sure that a user with the "user" role is unable to // First, we'll make sure that a user with the "user" role is unable to
// get the information of another user (in this case, the main admin). // get the information of another user (in this case, the main admin).
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser) unauthorizedUser := createUnpriviledgedUser(t, models.RoleUser)
url := fmt.Sprintf("/api/users/%d", s.admin.Id) url := fmt.Sprintf("/api/users/%d", testCtx.admin.Id)
r := httptest.NewRequest(http.MethodGet, url, nil) r := httptest.NewRequest(http.MethodGet, url, nil)
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey)) r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r) testCtx.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusForbidden) expected := http.StatusForbidden
if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
} }
// TestUnauthorizedModifyRole ensures that users without the ModifySystem // TestUnauthorizedModifyRole ensures that users without the ModifySystem
// privilege are unable to modify their own role, preventing a potential // privilege are unable to modify their own role, preventing a potential
// privilege escalation issue. // privilege escalation issue.
func (s *APISuite) TestUnauthorizedSetRole() { func TestUnauthorizedSetRole(t *testing.T) {
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser) testCtx := setupTest(t)
unauthorizedUser := createUnpriviledgedUser(t, models.RoleUser)
url := fmt.Sprintf("/api/users/%d", unauthorizedUser.Id) url := fmt.Sprintf("/api/users/%d", unauthorizedUser.Id)
payload := &userRequest{ payload := &userRequest{
Username: unauthorizedUser.Username, Username: unauthorizedUser.Username,
Role: models.RoleAdmin, Role: models.RoleAdmin,
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
s.Nil(err) if err != nil {
t.Fatalf("error marshaling userRequest payload: %v", err)
}
r := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(body)) r := httptest.NewRequest(http.MethodPut, url, bytes.NewBuffer(body))
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey)) r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r) testCtx.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusBadRequest) expected := http.StatusBadRequest
if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
response := &models.Response{} response := &models.Response{}
err = json.NewDecoder(w.Body).Decode(response) err = json.NewDecoder(w.Body).Decode(response)
s.Nil(err) if err != nil {
s.Equal(response.Message, ErrInsufficientPermission.Error()) t.Fatalf("error decoding response payload: %v", err)
}
if response.Message != ErrInsufficientPermission.Error() {
t.Fatalf("incorrect error received when setting role. expected %s got %s", ErrInsufficientPermission.Error(), response.Message)
}
} }
// TestModifyWithExistingUsername verifies that it's not possible to modify // TestModifyWithExistingUsername verifies that it's not possible to modify
// an user's username to one which already exists. // an user's username to one which already exists.
func (s *APISuite) TestModifyWithExistingUsername() { func TestModifyWithExistingUsername(t *testing.T) {
unauthorizedUser := s.createUnpriviledgedUser(models.RoleUser) testCtx := setupTest(t)
unauthorizedUser := createUnpriviledgedUser(t, models.RoleUser)
payload := &userRequest{ payload := &userRequest{
Username: s.admin.Username, Username: testCtx.admin.Username,
Role: unauthorizedUser.Role.Slug, Role: unauthorizedUser.Role.Slug,
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
s.Nil(err) if err != nil {
t.Fatalf("error marshaling userRequest payload: %v", err)
}
url := fmt.Sprintf("/api/users/%d", unauthorizedUser.Id) url := fmt.Sprintf("/api/users/%d", unauthorizedUser.Id)
r := httptest.NewRequest(http.MethodPut, url, bytes.NewReader(body)) r := httptest.NewRequest(http.MethodPut, url, bytes.NewReader(body))
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey)) r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", unauthorizedUser.ApiKey))
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.apiServer.ServeHTTP(w, r) testCtx.apiServer.ServeHTTP(w, r)
s.Equal(w.Code, http.StatusBadRequest) expected := http.StatusBadRequest
expected := &models.Response{ if w.Code != expected {
t.Fatalf("unexpected error code received. expected %d got %d", expected, w.Code)
}
expectedResponse := &models.Response{
Message: ErrUsernameTaken.Error(), Message: ErrUsernameTaken.Error(),
Success: false, Success: false,
} }
got := &models.Response{} got := &models.Response{}
err = json.NewDecoder(w.Body).Decode(got) err = json.NewDecoder(w.Body).Decode(got)
s.Nil(err) if err != nil {
s.Equal(got.Message, expected.Message) t.Fatalf("error decoding response payload: %v", err)
}
if got.Message != expectedResponse.Message {
t.Fatalf("incorrect error received when setting role. expected %s got %s", expectedResponse.Message, got.Message)
}
} }

View File

@ -3,6 +3,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/mail"
ctx "github.com/gophish/gophish/context" ctx "github.com/gophish/gophish/context"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
@ -93,7 +94,19 @@ func (as *Server) SendTestEmail(w http.ResponseWriter, r *http.Request) {
} }
s.SMTP = smtp s.SMTP = smtp
} }
_, err = mail.ParseAddress(s.Template.EnvelopeSender)
if err != nil {
_, err = mail.ParseAddress(s.SMTP.FromAddress)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
} else {
s.FromAddress = s.SMTP.FromAddress s.FromAddress = s.SMTP.FromAddress
}
} else {
s.FromAddress = s.Template.EnvelopeSender
}
// Validate the given request // Validate the given request
if err = s.Validate(); err != nil { if err = s.Validate(); err != nil {
@ -118,5 +131,4 @@ func (as *Server) SendTestEmail(w http.ResponseWriter, r *http.Request) {
return return
} }
JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK) JSONResponse(w, models.Response{Success: true, Message: "Email Sent"}, http.StatusOK)
return
} }

105
controllers/api/webhook.go Normal file
View File

@ -0,0 +1,105 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/gophish/gophish/webhook"
"github.com/gorilla/mux"
)
// Webhooks returns a list of webhooks, both active and disabled
func (as *Server) Webhooks(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
whs, err := models.GetWebhooks()
if err != nil {
log.Error(err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, whs, http.StatusOK)
case r.Method == "POST":
wh := models.Webhook{}
err := json.NewDecoder(r.Body).Decode(&wh)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest)
return
}
err = models.PostWebhook(&wh)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh, http.StatusCreated)
}
}
// Webhook returns details of a single webhook specified by "id" parameter
func (as *Server) Webhook(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64)
wh, err := models.GetWebhook(id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Webhook not found"}, http.StatusNotFound)
return
}
switch {
case r.Method == "GET":
JSONResponse(w, wh, http.StatusOK)
case r.Method == "DELETE":
err = models.DeleteWebhook(id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
log.Infof("Deleted webhook with id: %d", id)
JSONResponse(w, models.Response{Success: true, Message: "Webhook deleted Successfully!"}, http.StatusOK)
case r.Method == "PUT":
wh = models.Webhook{}
err = json.NewDecoder(r.Body).Decode(&wh)
if err != nil {
log.Errorf("error decoding webhook: %v", err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
wh.Id = id
err = models.PutWebhook(&wh)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh, http.StatusOK)
}
}
// ValidateWebhook makes an HTTP request to a specified remote url to ensure that it's valid.
func (as *Server) ValidateWebhook(w http.ResponseWriter, r *http.Request) {
type validationEvent struct {
Success bool `json:"success"`
}
switch {
case r.Method == "POST":
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64)
wh, err := models.GetWebhook(id)
if err != nil {
log.Error(err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
payload := validationEvent{Success: true}
err = webhook.Send(webhook.EndPoint{URL: wh.URL, Secret: wh.Secret}, payload)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, wh, http.StatusOK)
}
}

View File

@ -1,62 +1,88 @@
package controllers package controllers
import ( import (
"fmt"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"testing" "testing"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/stretchr/testify/suite"
) )
// ControllersSuite is a suite of tests to cover API related functions // testContext is the data required to test API related functions
type ControllersSuite struct { type testContext struct {
suite.Suite
apiKey string apiKey string
config *config.Config config *config.Config
adminServer *httptest.Server adminServer *httptest.Server
phishServer *httptest.Server phishServer *httptest.Server
origPath string
} }
func (s *ControllersSuite) SetupSuite() { func setupTest(t *testing.T) *testContext {
wd, _ := os.Getwd()
fmt.Println(wd)
conf := &config.Config{ conf := &config.Config{
DBName: "sqlite3", DBName: "sqlite3",
DBPath: ":memory:", DBPath: ":memory:",
MigrationsPath: "../db/db_sqlite3/migrations/", MigrationsPath: "../db/db_sqlite3/migrations/",
} }
abs, _ := filepath.Abs("../db/db_sqlite3/migrations/")
fmt.Printf("in controllers_test.go: %s\n", abs)
err := models.Setup(conf) err := models.Setup(conf)
if err != nil { if err != nil {
s.T().Fatalf("Failed creating database: %v", err) t.Fatalf("error setting up database: %v", err)
} }
s.config = conf ctx := &testContext{}
s.Nil(err) ctx.config = conf
// Setup the admin server for use in testing ctx.adminServer = httptest.NewUnstartedServer(NewAdminServer(ctx.config.AdminConf).server.Handler)
s.adminServer = httptest.NewUnstartedServer(NewAdminServer(s.config.AdminConf).server.Handler) ctx.adminServer.Config.Addr = ctx.config.AdminConf.ListenURL
s.adminServer.Config.Addr = s.config.AdminConf.ListenURL ctx.adminServer.Start()
s.adminServer.Start()
// Get the API key to use for these tests // Get the API key to use for these tests
u, err := models.GetUser(1) u, err := models.GetUser(1)
s.Nil(err) // Reset the temporary password for the admin user to a value we control
s.apiKey = u.ApiKey hash, err := auth.GeneratePasswordHash("gophish")
u.Hash = hash
models.PutUser(&u)
if err != nil {
t.Fatalf("error getting first user from database: %v", err)
}
// Create a second user to test account locked status
u2 := models.User{Username: "houdini", Hash: hash, AccountLocked: true}
models.PutUser(&u2)
if err != nil {
t.Fatalf("error creating new user: %v", err)
}
ctx.apiKey = u.ApiKey
// Start the phishing server // Start the phishing server
s.phishServer = httptest.NewUnstartedServer(NewPhishingServer(s.config.PhishConf).server.Handler) ctx.phishServer = httptest.NewUnstartedServer(NewPhishingServer(ctx.config.PhishConf).server.Handler)
s.phishServer.Config.Addr = s.config.PhishConf.ListenURL ctx.phishServer.Config.Addr = ctx.config.PhishConf.ListenURL
s.phishServer.Start() ctx.phishServer.Start()
// Move our cwd up to the project root for help with resolving // Move our cwd up to the project root for help with resolving
// static assets // static assets
origPath, _ := os.Getwd()
ctx.origPath = origPath
err = os.Chdir("../") err = os.Chdir("../")
s.Nil(err) if err != nil {
t.Fatalf("error changing directories to setup asset discovery: %v", err)
}
createTestData(t)
return ctx
} }
func (s *ControllersSuite) TearDownTest() { func tearDown(t *testing.T, ctx *testContext) {
campaigns, _ := models.GetCampaigns(1) // Tear down the admin and phishing servers
for _, campaign := range campaigns { ctx.adminServer.Close()
models.DeleteCampaign(campaign.Id) ctx.phishServer.Close()
} // Reset the path for the next test
os.Chdir(ctx.origPath)
} }
func (s *ControllersSuite) SetupTest() { func createTestData(t *testing.T) {
// Add a group // Add a group
group := models.Group{Name: "Test Group"} group := models.Group{Name: "Test Group"}
group.Targets = []models.Target{ group.Targets = []models.Target{
@ -67,12 +93,12 @@ func (s *ControllersSuite) SetupTest() {
models.PostGroup(&group) models.PostGroup(&group)
// Add a template // Add a template
t := models.Template{Name: "Test Template"} template := models.Template{Name: "Test Template"}
t.Subject = "Test subject" template.Subject = "Test subject"
t.Text = "Text text" template.Text = "Text text"
t.HTML = "<html>Test</html>" template.HTML = "<html>Test</html>"
t.UserId = 1 template.UserId = 1
models.PostTemplate(&t) models.PostTemplate(&template)
// Add a landing page // Add a landing page
p := models.Page{Name: "Test Page"} p := models.Page{Name: "Test Page"}
@ -91,20 +117,10 @@ func (s *ControllersSuite) SetupTest() {
// Set the status such that no emails are attempted // Set the status such that no emails are attempted
c := models.Campaign{Name: "Test campaign"} c := models.Campaign{Name: "Test campaign"}
c.UserId = 1 c.UserId = 1
c.Template = t c.Template = template
c.Page = p c.Page = p
c.SMTP = smtp c.SMTP = smtp
c.Groups = []models.Group{group} c.Groups = []models.Group{group}
models.PostCampaign(&c, c.UserId) models.PostCampaign(&c, c.UserId)
c.UpdateStatus(models.CampaignEmailsSent) c.UpdateStatus(models.CampaignEmailsSent)
} }
func (s *ControllersSuite) TearDownSuite() {
// Tear down the admin and phishing servers
s.adminServer.Close()
s.phishServer.Close()
}
func TestControllerSuite(t *testing.T) {
suite.Run(t, new(ControllersSuite))
}

View File

@ -82,19 +82,20 @@ func WithContactAddress(addr string) PhishingServerOption {
} }
// Start launches the phishing server, listening on the configured address. // Start launches the phishing server, listening on the configured address.
func (ps *PhishingServer) Start() error { func (ps *PhishingServer) Start() {
if ps.config.UseTLS { if ps.config.UseTLS {
// Only support TLS 1.2 and above - ref #1691, #1689
ps.server.TLSConfig = defaultTLSConfig
err := util.CheckAndCreateSSL(ps.config.CertPath, ps.config.KeyPath) err := util.CheckAndCreateSSL(ps.config.CertPath, ps.config.KeyPath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
return err
} }
log.Infof("Starting phishing server at https://%s", ps.config.ListenURL) log.Infof("Starting phishing server at https://%s", ps.config.ListenURL)
return ps.server.ListenAndServeTLS(ps.config.CertPath, ps.config.KeyPath) log.Fatal(ps.server.ListenAndServeTLS(ps.config.CertPath, ps.config.KeyPath))
} }
// If TLS isn't configured, just listen on HTTP // If TLS isn't configured, just listen on HTTP
log.Infof("Starting phishing server at http://%s", ps.config.ListenURL) log.Infof("Starting phishing server at http://%s", ps.config.ListenURL)
return ps.server.ListenAndServe() log.Fatal(ps.server.ListenAndServe())
} }
// Shutdown attempts to gracefully shutdown the server. // Shutdown attempts to gracefully shutdown the server.
@ -120,6 +121,10 @@ func (ps *PhishingServer) registerRoutes() {
gzipWrapper, _ := gziphandler.NewGzipLevelHandler(gzip.BestCompression) gzipWrapper, _ := gziphandler.NewGzipLevelHandler(gzip.BestCompression)
phishHandler := gzipWrapper(router) phishHandler := gzipWrapper(router)
// Respect X-Forwarded-For and X-Real-IP headers in case we're behind a
// reverse proxy.
phishHandler = handlers.ProxyHeaders(phishHandler)
// Setup logging // Setup logging
phishHandler = handlers.CombinedLoggingHandler(log.Writer(), phishHandler) phishHandler = handlers.CombinedLoggingHandler(log.Writer(), phishHandler)
ps.server.Handler = phishHandler ps.server.Handler = phishHandler
@ -161,6 +166,7 @@ func (ps *PhishingServer) TrackHandler(w http.ResponseWriter, r *http.Request) {
// ReportHandler tracks emails as they are reported, updating the status for the given Result // ReportHandler tracks emails as they are reported, updating the status for the given Result
func (ps *PhishingServer) ReportHandler(w http.ResponseWriter, r *http.Request) { func (ps *PhishingServer) ReportHandler(w http.ResponseWriter, r *http.Request) {
r, err := setupContext(r) r, err := setupContext(r)
w.Header().Set("Access-Control-Allow-Origin", "*") // To allow Chrome extensions (or other pages) to report a campaign without violating CORS
if err != nil { if err != nil {
// Log the error if it wasn't something we can safely ignore // Log the error if it wasn't something we can safely ignore
if err != ErrInvalidRequest && err != ErrCampaignComplete { if err != ErrInvalidRequest && err != ErrCampaignComplete {
@ -203,6 +209,7 @@ func (ps *PhishingServer) PhishHandler(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
w.Header().Set("X-Server", config.ServerName) // Useful for checking if this is a GoPhish server (e.g. for campaign reporting plugins)
var ptx models.PhishingTemplateContext var ptx models.PhishingTemplateContext
// Check for a preview // Check for a preview
if preview, ok := ctx.Get(r, "result").(models.EmailRequest); ok { if preview, ok := ctx.Get(r, "result").(models.EmailRequest); ok {
@ -353,12 +360,7 @@ func setupContext(r *http.Request) (*http.Request, error) {
} }
ip, _, err := net.SplitHostPort(r.RemoteAddr) ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
log.Error(err) ip = r.RemoteAddr
return r, err
}
// Respect X-Forwarded headers
if fips := r.Header.Get("X-Forwarded-For"); fips != "" {
ip = strings.Split(fips, ", ")[0]
} }
// Handle post processing such as GeoIP // Handle post processing such as GeoIP
err = rs.UpdateGeo(ip) err = rs.UpdateGeo(ip)

View File

@ -5,22 +5,25 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"net/http" "net/http"
"net/url" "net/url"
"reflect"
"testing"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
) )
func (s *ControllersSuite) getFirstCampaign() models.Campaign { func getFirstCampaign(t *testing.T) models.Campaign {
campaigns, err := models.GetCampaigns(1) campaigns, err := models.GetCampaigns(1)
s.Nil(err) if err != nil {
t.Fatalf("error getting first campaign from database: %v", err)
}
return campaigns[0] return campaigns[0]
} }
func (s *ControllersSuite) getFirstEmailRequest() models.EmailRequest { func getFirstEmailRequest(t *testing.T) models.EmailRequest {
campaign := s.getFirstCampaign() campaign := getFirstCampaign(t)
req := models.EmailRequest{ req := models.EmailRequest{
TemplateId: campaign.TemplateId, TemplateId: campaign.TemplateId,
Template: campaign.Template, Template: campaign.Template,
@ -33,205 +36,334 @@ func (s *ControllersSuite) getFirstEmailRequest() models.EmailRequest {
FromAddress: campaign.SMTP.FromAddress, FromAddress: campaign.SMTP.FromAddress,
} }
err := models.PostEmailRequest(&req) err := models.PostEmailRequest(&req)
s.Nil(err) if err != nil {
t.Fatalf("error creating email request: %v", err)
}
return req return req
} }
func (s *ControllersSuite) openEmail(rid string) { func openEmail(t *testing.T, ctx *testContext, rid string) {
resp, err := http.Get(fmt.Sprintf("%s/track?%s=%s", s.phishServer.URL, models.RecipientParameter, rid)) resp, err := http.Get(fmt.Sprintf("%s/track?%s=%s", ctx.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err) if err != nil {
t.Fatalf("error requesting /track endpoint: %v", err)
}
defer resp.Body.Close() defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body) got, err := ioutil.ReadAll(resp.Body)
s.Nil(err) if err != nil {
t.Fatalf("error reading response body from /track endpoint: %v", err)
}
expected, err := ioutil.ReadFile("static/images/pixel.png") expected, err := ioutil.ReadFile("static/images/pixel.png")
s.Nil(err) if err != nil {
s.Equal(bytes.Compare(body, expected), 0) t.Fatalf("error reading local transparent pixel: %v", err)
}
if !bytes.Equal(got, expected) {
t.Fatalf("unexpected tracking pixel data received. expected %#v got %#v", expected, got)
}
} }
func (s *ControllersSuite) reportedEmail(rid string) { func openEmail404(t *testing.T, ctx *testContext, rid string) {
resp, err := http.Get(fmt.Sprintf("%s/report?%s=%s", s.phishServer.URL, models.RecipientParameter, rid)) resp, err := http.Get(fmt.Sprintf("%s/track?%s=%s", ctx.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err) if err != nil {
s.Equal(resp.StatusCode, http.StatusNoContent) t.Fatalf("error requesting /track endpoint: %v", err)
} }
func (s *ControllersSuite) reportEmail404(rid string) {
resp, err := http.Get(fmt.Sprintf("%s/report?%s=%s", s.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err)
s.Equal(resp.StatusCode, http.StatusNotFound)
}
func (s *ControllersSuite) openEmail404(rid string) {
resp, err := http.Get(fmt.Sprintf("%s/track?%s=%s", s.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err)
defer resp.Body.Close() defer resp.Body.Close()
s.Nil(err) got := resp.StatusCode
s.Equal(resp.StatusCode, http.StatusNotFound) expected := http.StatusNotFound
if got != expected {
t.Fatalf("invalid status code received for /track endpoint. expected %d got %d", expected, got)
}
} }
func (s *ControllersSuite) clickLink(rid string, expectedHTML string) { func reportedEmail(t *testing.T, ctx *testContext, rid string) {
resp, err := http.Get(fmt.Sprintf("%s/?%s=%s", s.phishServer.URL, models.RecipientParameter, rid)) resp, err := http.Get(fmt.Sprintf("%s/report?%s=%s", ctx.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err) if err != nil {
defer resp.Body.Close() t.Fatalf("error requesting /report endpoint: %v", err)
body, err := ioutil.ReadAll(resp.Body) }
s.Nil(err) got := resp.StatusCode
log.Printf("%s\n\n\n", body) expected := http.StatusNoContent
s.Equal(bytes.Compare(body, []byte(expectedHTML)), 0) if got != expected {
t.Fatalf("invalid status code received for /report endpoint. expected %d got %d", expected, got)
}
} }
func (s *ControllersSuite) clickLink404(rid string) { func reportEmail404(t *testing.T, ctx *testContext, rid string) {
resp, err := http.Get(fmt.Sprintf("%s/?%s=%s", s.phishServer.URL, models.RecipientParameter, rid)) resp, err := http.Get(fmt.Sprintf("%s/report?%s=%s", ctx.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err) if err != nil {
defer resp.Body.Close() t.Fatalf("error requesting /report endpoint: %v", err)
s.Nil(err) }
s.Equal(resp.StatusCode, http.StatusNotFound) got := resp.StatusCode
expected := http.StatusNotFound
if got != expected {
t.Fatalf("invalid status code received for /report endpoint. expected %d got %d", expected, got)
}
} }
func (s *ControllersSuite) transparencyRequest(r models.Result, rid, path string) { func clickLink(t *testing.T, ctx *testContext, rid string, expectedHTML string) {
resp, err := http.Get(fmt.Sprintf("%s%s?%s=%s", s.phishServer.URL, path, models.RecipientParameter, rid)) resp, err := http.Get(fmt.Sprintf("%s/?%s=%s", ctx.phishServer.URL, models.RecipientParameter, rid))
s.Nil(err) if err != nil {
t.Fatalf("error requesting / endpoint: %v", err)
}
defer resp.Body.Close() defer resp.Body.Close()
s.Equal(resp.StatusCode, http.StatusOK) got, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("error reading payload from / endpoint response: %v", err)
}
if !bytes.Equal(got, []byte(expectedHTML)) {
t.Fatalf("invalid response received from / endpoint. expected %s got %s", got, expectedHTML)
}
}
func clickLink404(t *testing.T, ctx *testContext, rid string) {
resp, err := http.Get(fmt.Sprintf("%s/?%s=%s", ctx.phishServer.URL, models.RecipientParameter, rid))
if err != nil {
t.Fatalf("error requesting / endpoint: %v", err)
}
defer resp.Body.Close()
got := resp.StatusCode
expected := http.StatusNotFound
if got != expected {
t.Fatalf("invalid status code received for / endpoint. expected %d got %d", expected, got)
}
}
func transparencyRequest(t *testing.T, ctx *testContext, r models.Result, rid, path string) {
resp, err := http.Get(fmt.Sprintf("%s%s?%s=%s", ctx.phishServer.URL, path, models.RecipientParameter, rid))
if err != nil {
t.Fatalf("error requesting %s endpoint: %v", path, err)
}
defer resp.Body.Close()
got := resp.StatusCode
expected := http.StatusOK
if got != expected {
t.Fatalf("invalid status code received for / endpoint. expected %d got %d", expected, got)
}
tr := &TransparencyResponse{} tr := &TransparencyResponse{}
err = json.NewDecoder(resp.Body).Decode(tr) err = json.NewDecoder(resp.Body).Decode(tr)
s.Nil(err) if err != nil {
s.Equal(tr.ContactAddress, s.config.ContactAddress) t.Fatalf("error unmarshaling transparency request: %v", err)
s.Equal(tr.SendDate, r.SendDate) }
s.Equal(tr.Server, config.ServerName) expectedResponse := &TransparencyResponse{
ContactAddress: ctx.config.ContactAddress,
SendDate: r.SendDate,
Server: config.ServerName,
}
if !reflect.DeepEqual(tr, expectedResponse) {
t.Fatalf("unexpected transparency response received. expected %v got %v", expectedResponse, tr)
}
} }
func (s *ControllersSuite) TestOpenedPhishingEmail() { func TestOpenedPhishingEmail(t *testing.T) {
campaign := s.getFirstCampaign() ctx := setupTest(t)
defer tearDown(t, ctx)
campaign := getFirstCampaign(t)
result := campaign.Results[0] result := campaign.Results[0]
s.Equal(result.Status, models.StatusSending) if result.Status != models.StatusSending {
t.Fatalf("unexpected result status received. expected %s got %s", models.StatusSending, result.Status)
}
s.openEmail(result.RId) openEmail(t, ctx, result.RId)
campaign = s.getFirstCampaign() campaign = getFirstCampaign(t)
result = campaign.Results[0] result = campaign.Results[0]
lastEvent := campaign.Events[len(campaign.Events)-1] lastEvent := campaign.Events[len(campaign.Events)-1]
s.Equal(result.Status, models.EventOpened) if result.Status != models.EventOpened {
s.Equal(lastEvent.Message, models.EventOpened) t.Fatalf("unexpected result status received. expected %s got %s", models.EventOpened, result.Status)
s.Equal(result.ModifiedDate, lastEvent.Time) }
if lastEvent.Message != models.EventOpened {
t.Fatalf("unexpected event status received. expected %s got %s", lastEvent.Message, models.EventOpened)
}
if result.ModifiedDate != lastEvent.Time {
t.Fatalf("unexpected result modified date received. expected %s got %s", lastEvent.Time, result.ModifiedDate)
}
} }
func (s *ControllersSuite) TestReportedPhishingEmail() { func TestReportedPhishingEmail(t *testing.T) {
campaign := s.getFirstCampaign() ctx := setupTest(t)
defer tearDown(t, ctx)
campaign := getFirstCampaign(t)
result := campaign.Results[0] result := campaign.Results[0]
s.Equal(result.Status, models.StatusSending) if result.Status != models.StatusSending {
t.Fatalf("unexpected result status received. expected %s got %s", models.StatusSending, result.Status)
}
s.reportedEmail(result.RId) reportedEmail(t, ctx, result.RId)
campaign = s.getFirstCampaign() campaign = getFirstCampaign(t)
result = campaign.Results[0] result = campaign.Results[0]
lastEvent := campaign.Events[len(campaign.Events)-1] lastEvent := campaign.Events[len(campaign.Events)-1]
s.Equal(result.Reported, true)
s.Equal(lastEvent.Message, models.EventReported) if result.Reported != true {
s.Equal(result.ModifiedDate, lastEvent.Time) t.Fatalf("unexpected result report status received. expected %v got %v", true, result.Reported)
}
if lastEvent.Message != models.EventReported {
t.Fatalf("unexpected event status received. expected %s got %s", lastEvent.Message, models.EventReported)
}
if result.ModifiedDate != lastEvent.Time {
t.Fatalf("unexpected result modified date received. expected %s got %s", lastEvent.Time, result.ModifiedDate)
}
} }
func (s *ControllersSuite) TestClickedPhishingLinkAfterOpen() { func TestClickedPhishingLinkAfterOpen(t *testing.T) {
campaign := s.getFirstCampaign() ctx := setupTest(t)
defer tearDown(t, ctx)
campaign := getFirstCampaign(t)
result := campaign.Results[0] result := campaign.Results[0]
s.Equal(result.Status, models.StatusSending) if result.Status != models.StatusSending {
t.Fatalf("unexpected result status received. expected %s got %s", models.StatusSending, result.Status)
}
s.openEmail(result.RId) openEmail(t, ctx, result.RId)
s.clickLink(result.RId, campaign.Page.HTML) clickLink(t, ctx, result.RId, campaign.Page.HTML)
campaign = s.getFirstCampaign() campaign = getFirstCampaign(t)
result = campaign.Results[0] result = campaign.Results[0]
lastEvent := campaign.Events[len(campaign.Events)-1] lastEvent := campaign.Events[len(campaign.Events)-1]
s.Equal(result.Status, models.EventClicked) if result.Status != models.EventClicked {
s.Equal(lastEvent.Message, models.EventClicked) t.Fatalf("unexpected result status received. expected %s got %s", models.EventClicked, result.Status)
s.Equal(result.ModifiedDate, lastEvent.Time) }
if lastEvent.Message != models.EventClicked {
t.Fatalf("unexpected event status received. expected %s got %s", lastEvent.Message, models.EventClicked)
}
if result.ModifiedDate != lastEvent.Time {
t.Fatalf("unexpected result modified date received. expected %s got %s", lastEvent.Time, result.ModifiedDate)
}
} }
func (s *ControllersSuite) TestNoRecipientID() { func TestNoRecipientID(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/track", s.phishServer.URL)) ctx := setupTest(t)
s.Nil(err) defer tearDown(t, ctx)
s.Equal(resp.StatusCode, http.StatusNotFound) resp, err := http.Get(fmt.Sprintf("%s/track", ctx.phishServer.URL))
if err != nil {
resp, err = http.Get(s.phishServer.URL) t.Fatalf("error requesting /track endpoint: %v", err)
s.Nil(err) }
s.Equal(resp.StatusCode, http.StatusNotFound) got := resp.StatusCode
expected := http.StatusNotFound
if got != expected {
t.Fatalf("invalid status code received for /track endpoint. expected %d got %d", expected, got)
} }
func (s *ControllersSuite) TestInvalidRecipientID() { resp, err = http.Get(ctx.phishServer.URL)
if err != nil {
t.Fatalf("error requesting /track endpoint: %v", err)
}
got = resp.StatusCode
if got != expected {
t.Fatalf("invalid status code received for / endpoint. expected %d got %d", expected, got)
}
}
func TestInvalidRecipientID(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
rid := "XXXXXXXXXX" rid := "XXXXXXXXXX"
s.openEmail404(rid) openEmail404(t, ctx, rid)
s.clickLink404(rid) clickLink404(t, ctx, rid)
s.reportEmail404(rid) reportEmail404(t, ctx, rid)
} }
func (s *ControllersSuite) TestCompletedCampaignClick() { func TestCompletedCampaignClick(t *testing.T) {
campaign := s.getFirstCampaign() ctx := setupTest(t)
defer tearDown(t, ctx)
campaign := getFirstCampaign(t)
result := campaign.Results[0] result := campaign.Results[0]
s.Equal(result.Status, models.StatusSending) if result.Status != models.StatusSending {
s.openEmail(result.RId) t.Fatalf("unexpected result status received. expected %s got %s", models.StatusSending, result.Status)
}
campaign = s.getFirstCampaign() openEmail(t, ctx, result.RId)
campaign = getFirstCampaign(t)
result = campaign.Results[0] result = campaign.Results[0]
s.Equal(result.Status, models.EventOpened) if result.Status != models.EventOpened {
t.Fatalf("unexpected result status received. expected %s got %s", models.EventOpened, result.Status)
}
models.CompleteCampaign(campaign.Id, 1) models.CompleteCampaign(campaign.Id, 1)
s.openEmail404(result.RId) openEmail404(t, ctx, result.RId)
s.clickLink404(result.RId) clickLink404(t, ctx, result.RId)
campaign = s.getFirstCampaign() campaign = getFirstCampaign(t)
result = campaign.Results[0] result = campaign.Results[0]
s.Equal(result.Status, models.EventOpened) if result.Status != models.EventOpened {
t.Fatalf("unexpected result status received. expected %s got %s", models.EventOpened, result.Status)
}
} }
func (s *ControllersSuite) TestRobotsHandler() { func TestRobotsHandler(t *testing.T) {
expected := []byte("User-agent: *\nDisallow: /\n") ctx := setupTest(t)
resp, err := http.Get(fmt.Sprintf("%s/robots.txt", s.phishServer.URL)) defer tearDown(t, ctx)
s.Nil(err) resp, err := http.Get(fmt.Sprintf("%s/robots.txt", ctx.phishServer.URL))
s.Equal(resp.StatusCode, http.StatusOK) if err != nil {
t.Fatalf("error requesting /robots.txt endpoint: %v", err)
}
defer resp.Body.Close() defer resp.Body.Close()
got := resp.StatusCode
expectedStatus := http.StatusOK
if got != expectedStatus {
t.Fatalf("invalid status code received for /track endpoint. expected %d got %d", expectedStatus, got)
}
expected := []byte("User-agent: *\nDisallow: /\n")
body, err := ioutil.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
s.Nil(err) if err != nil {
s.Equal(bytes.Compare(body, expected), 0) t.Fatalf("error reading response body from /robots.txt endpoint: %v", err)
}
if !bytes.Equal(body, expected) {
t.Fatalf("invalid robots.txt response received. expected %s got %s", expected, body)
}
} }
func (s *ControllersSuite) TestInvalidPreviewID() { func TestInvalidPreviewID(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
bogusRId := fmt.Sprintf("%sbogus", models.PreviewPrefix) bogusRId := fmt.Sprintf("%sbogus", models.PreviewPrefix)
s.openEmail404(bogusRId) openEmail404(t, ctx, bogusRId)
s.clickLink404(bogusRId) clickLink404(t, ctx, bogusRId)
s.reportEmail404(bogusRId) reportEmail404(t, ctx, bogusRId)
} }
func (s *ControllersSuite) TestPreviewTrack() { func TestPreviewTrack(t *testing.T) {
req := s.getFirstEmailRequest() ctx := setupTest(t)
s.openEmail(req.RId) defer tearDown(t, ctx)
req := getFirstEmailRequest(t)
openEmail(t, ctx, req.RId)
} }
func (s *ControllersSuite) TestPreviewClick() { func TestPreviewClick(t *testing.T) {
req := s.getFirstEmailRequest() ctx := setupTest(t)
s.clickLink(req.RId, req.Page.HTML) defer tearDown(t, ctx)
req := getFirstEmailRequest(t)
clickLink(t, ctx, req.RId, req.Page.HTML)
} }
func (s *ControllersSuite) TestInvalidTransparencyRequest() { func TestInvalidTransparencyRequest(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
bogusRId := fmt.Sprintf("bogus%s", TransparencySuffix) bogusRId := fmt.Sprintf("bogus%s", TransparencySuffix)
s.openEmail404(bogusRId) openEmail404(t, ctx, bogusRId)
s.clickLink404(bogusRId) clickLink404(t, ctx, bogusRId)
s.reportEmail404(bogusRId) reportEmail404(t, ctx, bogusRId)
} }
func (s *ControllersSuite) TestTransparencyRequest() { func TestTransparencyRequest(t *testing.T) {
campaign := s.getFirstCampaign() ctx := setupTest(t)
defer tearDown(t, ctx)
campaign := getFirstCampaign(t)
result := campaign.Results[0] result := campaign.Results[0]
rid := fmt.Sprintf("%s%s", result.RId, TransparencySuffix) rid := fmt.Sprintf("%s%s", result.RId, TransparencySuffix)
s.transparencyRequest(result, rid, "/") transparencyRequest(t, ctx, result, rid, "/")
s.transparencyRequest(result, rid, "/track") transparencyRequest(t, ctx, result, rid, "/track")
s.transparencyRequest(result, rid, "/report") transparencyRequest(t, ctx, result, rid, "/report")
// And check with the URL encoded version of a + // And check with the URL encoded version of a +
rid = fmt.Sprintf("%s%s", result.RId, "%2b") rid = fmt.Sprintf("%s%s", result.RId, "%2b")
s.transparencyRequest(result, rid, "/") transparencyRequest(t, ctx, result, rid, "/")
s.transparencyRequest(result, rid, "/track") transparencyRequest(t, ctx, result, rid, "/track")
s.transparencyRequest(result, rid, "/report") transparencyRequest(t, ctx, result, rid, "/report")
} }
func (s *ControllersSuite) TestRedirectTemplating() { func TestRedirectTemplating(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
p := models.Page{ p := models.Page{
Name: "Redirect Page", Name: "Redirect Page",
HTML: "<html>Test</html>", HTML: "<html>Test</html>",
@ -239,7 +371,9 @@ func (s *ControllersSuite) TestRedirectTemplating() {
RedirectURL: "http://example.com/{{.RId}}", RedirectURL: "http://example.com/{{.RId}}",
} }
err := models.PostPage(&p) err := models.PostPage(&p)
s.Nil(err) if err != nil {
t.Fatalf("error posting new page: %v", err)
}
smtp, _ := models.GetSMTP(1, 1) smtp, _ := models.GetSMTP(1, 1)
template, _ := models.GetTemplate(1, 1) template, _ := models.GetTemplate(1, 1)
group, _ := models.GetGroup(1, 1) group, _ := models.GetGroup(1, 1)
@ -251,7 +385,9 @@ func (s *ControllersSuite) TestRedirectTemplating() {
campaign.SMTP = smtp campaign.SMTP = smtp
campaign.Groups = []models.Group{group} campaign.Groups = []models.Group{group}
err = models.PostCampaign(&campaign, campaign.UserId) err = models.PostCampaign(&campaign, campaign.UserId)
s.Nil(err) if err != nil {
t.Fatalf("error creating campaign: %v", err)
}
client := http.Client{ client := http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
@ -259,12 +395,22 @@ func (s *ControllersSuite) TestRedirectTemplating() {
}, },
} }
result := campaign.Results[0] result := campaign.Results[0]
resp, err := client.PostForm(fmt.Sprintf("%s/?%s=%s", s.phishServer.URL, models.RecipientParameter, result.RId), url.Values{"username": {"test"}, "password": {"test"}}) resp, err := client.PostForm(fmt.Sprintf("%s/?%s=%s", ctx.phishServer.URL, models.RecipientParameter, result.RId), url.Values{"username": {"test"}, "password": {"test"}})
s.Nil(err) if err != nil {
defer resp.Body.Close() t.Fatalf("error requesting / endpoint: %v", err)
s.Equal(http.StatusFound, resp.StatusCode) }
expectedURL := fmt.Sprintf("http://example.com/%s", result.RId) defer resp.Body.Close()
got, err := resp.Location() got := resp.StatusCode
s.Nil(err) expectedStatus := http.StatusFound
s.Equal(expectedURL, got.String()) if got != expectedStatus {
t.Fatalf("invalid status code received for /track endpoint. expected %d got %d", expectedStatus, got)
}
expectedURL := fmt.Sprintf("http://example.com/%s", result.RId)
gotURL, err := resp.Location()
if err != nil {
t.Fatalf("error getting Location header from response: %v", err)
}
if gotURL.String() != expectedURL {
t.Fatalf("invalid redirect received. expected %s got %s", expectedURL, gotURL)
}
} }

View File

@ -3,9 +3,11 @@ package controllers
import ( import (
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/tls"
"html/template" "html/template"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/NYTimes/gziphandler" "github.com/NYTimes/gziphandler"
@ -15,6 +17,7 @@ import (
"github.com/gophish/gophish/controllers/api" "github.com/gophish/gophish/controllers/api"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
mid "github.com/gophish/gophish/middleware" mid "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/middleware/ratelimit"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util" "github.com/gophish/gophish/util"
"github.com/gophish/gophish/worker" "github.com/gophish/gophish/worker"
@ -35,6 +38,28 @@ type AdminServer struct {
server *http.Server server *http.Server
worker worker.Worker worker worker.Worker
config config.AdminServer config config.AdminServer
limiter *ratelimit.PostLimiter
}
var defaultTLSConfig = &tls.Config{
PreferServerCipherSuites: true,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
// Kept for backwards compatibility with some clients
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
},
} }
// WithWorker is an option that sets the background worker. // WithWorker is an option that sets the background worker.
@ -52,9 +77,11 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
Addr: config.ListenURL, Addr: config.ListenURL,
} }
defaultLimiter := ratelimit.NewPostLimiter()
as := &AdminServer{ as := &AdminServer{
worker: defaultWorker, worker: defaultWorker,
server: defaultServer, server: defaultServer,
limiter: defaultLimiter,
config: config, config: config,
} }
for _, opt := range options { for _, opt := range options {
@ -65,22 +92,23 @@ func NewAdminServer(config config.AdminServer, options ...AdminServerOption) *Ad
} }
// Start launches the admin server, listening on the configured address. // Start launches the admin server, listening on the configured address.
func (as *AdminServer) Start() error { func (as *AdminServer) Start() {
if as.worker != nil { if as.worker != nil {
go as.worker.Start() go as.worker.Start()
} }
if as.config.UseTLS { if as.config.UseTLS {
// Only support TLS 1.2 and above - ref #1691, #1689
as.server.TLSConfig = defaultTLSConfig
err := util.CheckAndCreateSSL(as.config.CertPath, as.config.KeyPath) err := util.CheckAndCreateSSL(as.config.CertPath, as.config.KeyPath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
return err
} }
log.Infof("Starting admin server at https://%s", as.config.ListenURL) log.Infof("Starting admin server at https://%s", as.config.ListenURL)
return as.server.ListenAndServeTLS(as.config.CertPath, as.config.KeyPath) log.Fatal(as.server.ListenAndServeTLS(as.config.CertPath, as.config.KeyPath))
} }
// If TLS isn't configured, just listen on HTTP // If TLS isn't configured, just listen on HTTP
log.Infof("Starting admin server at http://%s", as.config.ListenURL) log.Infof("Starting admin server at http://%s", as.config.ListenURL)
return as.server.ListenAndServe() log.Fatal(as.server.ListenAndServe())
} }
// Shutdown attempts to gracefully shutdown the server. // Shutdown attempts to gracefully shutdown the server.
@ -96,8 +124,9 @@ func (as *AdminServer) registerRoutes() {
router := mux.NewRouter() router := mux.NewRouter()
// Base Front-end routes // Base Front-end routes
router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin)) router.HandleFunc("/", mid.Use(as.Base, mid.RequireLogin))
router.HandleFunc("/login", as.Login) router.HandleFunc("/login", mid.Use(as.Login, as.limiter.Limit))
router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin)) router.HandleFunc("/logout", mid.Use(as.Logout, mid.RequireLogin))
router.HandleFunc("/reset_password", mid.Use(as.ResetPassword, mid.RequireLogin))
router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin)) router.HandleFunc("/campaigns", mid.Use(as.Campaigns, mid.RequireLogin))
router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin)) router.HandleFunc("/campaigns/{id:[0-9]+}", mid.Use(as.CampaignID, mid.RequireLogin))
router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin)) router.HandleFunc("/templates", mid.Use(as.Templates, mid.RequireLogin))
@ -106,24 +135,38 @@ func (as *AdminServer) registerRoutes() {
router.HandleFunc("/sending_profiles", mid.Use(as.SendingProfiles, mid.RequireLogin)) router.HandleFunc("/sending_profiles", mid.Use(as.SendingProfiles, mid.RequireLogin))
router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin)) router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin))
router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin)) router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/impersonate", mid.Use(as.Impersonate, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
// Create the API routes // Create the API routes
api := api.NewServer(api.WithWorker(as.worker)) api := api.NewServer(
api.WithWorker(as.worker),
api.WithLimiter(as.limiter),
)
router.PathPrefix("/api/").Handler(api) router.PathPrefix("/api/").Handler(api)
// Setup static file serving // Setup static file serving
router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/"))) router.PathPrefix("/").Handler(http.FileServer(unindexed.Dir("./static/")))
// Setup CSRF Protection // Setup CSRF Protection
csrfHandler := csrf.Protect([]byte(util.GenerateSecureKey()), csrfKey := []byte(as.config.CSRFKey)
if len(csrfKey) == 0 {
csrfKey = []byte(auth.GenerateSecureKey(auth.APIKeyLength))
}
csrfHandler := csrf.Protect(csrfKey,
csrf.FieldName("csrf_token"), csrf.FieldName("csrf_token"),
csrf.Secure(as.config.UseTLS)) csrf.Secure(as.config.UseTLS),
csrf.TrustedOrigins(as.config.TrustedOrigins))
adminHandler := csrfHandler(router) adminHandler := csrfHandler(router)
adminHandler = mid.Use(adminHandler.ServeHTTP, mid.CSRFExceptions, mid.GetContext) adminHandler = mid.Use(adminHandler.ServeHTTP, mid.CSRFExceptions, mid.GetContext, mid.ApplySecurityHeaders)
// Setup GZIP compression // Setup GZIP compression
gzipWrapper, _ := gziphandler.NewGzipLevelHandler(gzip.BestCompression) gzipWrapper, _ := gziphandler.NewGzipLevelHandler(gzip.BestCompression)
adminHandler = gzipWrapper(adminHandler) adminHandler = gzipWrapper(adminHandler)
// Respect X-Forwarded-For and X-Real-IP headers in case we're behind a
// reverse proxy.
adminHandler = handlers.ProxyHeaders(adminHandler)
// Setup logging // Setup logging
adminHandler = handlers.CombinedLoggingHandler(log.Writer(), adminHandler) adminHandler = handlers.CombinedLoggingHandler(log.Writer(), adminHandler)
as.server.Handler = adminHandler as.server.Handler = adminHandler
@ -142,12 +185,14 @@ type templateParams struct {
// the CSRF token. // the CSRF token.
func newTemplateParams(r *http.Request) templateParams { func newTemplateParams(r *http.Request) templateParams {
user := ctx.Get(r, "user").(models.User) user := ctx.Get(r, "user").(models.User)
session := ctx.Get(r, "session").(*sessions.Session)
modifySystem, _ := user.HasPermission(models.PermissionModifySystem) modifySystem, _ := user.HasPermission(models.PermissionModifySystem)
return templateParams{ return templateParams{
Token: csrf.Token(r), Token: csrf.Token(r),
User: user, User: user,
ModifySystem: modifySystem, ModifySystem: modifySystem,
Version: config.Version, Version: config.Version,
Flashes: session.Flashes(),
} }
} }
@ -206,22 +251,37 @@ func (as *AdminServer) Settings(w http.ResponseWriter, r *http.Request) {
case r.Method == "GET": case r.Method == "GET":
params := newTemplateParams(r) params := newTemplateParams(r)
params.Title = "Settings" params.Title = "Settings"
session := ctx.Get(r, "session").(*sessions.Session)
session.Save(r, w)
getTemplate(w, "settings").ExecuteTemplate(w, "base", params) getTemplate(w, "settings").ExecuteTemplate(w, "base", params)
case r.Method == "POST": case r.Method == "POST":
err := auth.ChangePassword(r) u := ctx.Get(r, "user").(models.User)
currentPw := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_new_password")
// Check the current password
err := auth.ValidatePassword(currentPw, u.Hash)
msg := models.Response{Success: true, Message: "Settings Updated Successfully"} msg := models.Response{Success: true, Message: "Settings Updated Successfully"}
if err == auth.ErrInvalidPassword {
msg.Message = "Invalid Password"
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
if err != nil { if err != nil {
msg.Message = err.Error() msg.Message = err.Error()
msg.Success = false msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest) api.JSONResponse(w, msg, http.StatusBadRequest)
return return
} }
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
if err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusBadRequest)
return
}
u.Hash = string(newHash)
if err = models.PutUser(&u); err != nil {
msg.Message = err.Error()
msg.Success = false
api.JSONResponse(w, msg, http.StatusInternalServerError)
return
}
api.JSONResponse(w, msg, http.StatusOK) api.JSONResponse(w, msg, http.StatusOK)
} }
} }
@ -234,6 +294,64 @@ func (as *AdminServer) UserManagement(w http.ResponseWriter, r *http.Request) {
getTemplate(w, "users").ExecuteTemplate(w, "base", params) getTemplate(w, "users").ExecuteTemplate(w, "base", params)
} }
func (as *AdminServer) nextOrIndex(w http.ResponseWriter, r *http.Request) {
next := "/"
url, err := url.Parse(r.FormValue("next"))
if err == nil {
path := url.EscapedPath()
if path != "" {
next = "/" + strings.TrimLeft(path, "/")
}
}
http.Redirect(w, r, next, http.StatusFound)
}
func (as *AdminServer) handleInvalidLogin(w http.ResponseWriter, r *http.Request, message string) {
session := ctx.Get(r, "session").(*sessions.Session)
Flash(w, r, "danger", message)
params := struct {
User models.User
Title string
Flashes []interface{}
Token string
}{Title: "Login", Token: csrf.Token(r)}
params.Flashes = session.Flashes()
session.Save(r, w)
templates := template.New("template")
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
if err != nil {
log.Error(err)
}
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
template.Must(templates, err).ExecuteTemplate(w, "base", params)
}
// Webhooks is an admin-only handler that handles webhooks
func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
params.Title = "Webhooks"
getTemplate(w, "webhooks").ExecuteTemplate(w, "base", params)
}
// Impersonate allows an admin to login to a user account without needing the password
func (as *AdminServer) Impersonate(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
username := r.FormValue("username")
u, err := models.GetUserByUsername(username)
if err != nil {
log.Error(err)
http.Error(w, err.Error(), http.StatusNotFound)
return
}
session := ctx.Get(r, "session").(*sessions.Session)
session.Values["id"] = u.Id
session.Save(r, w)
}
http.Redirect(w, r, "/", http.StatusFound)
}
// Login handles the authentication flow for a user. If credentials are valid, // Login handles the authentication flow for a user. If credentials are valid,
// a session is created // a session is created
func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) { func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
@ -255,37 +373,34 @@ func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {
} }
template.Must(templates, err).ExecuteTemplate(w, "base", params) template.Must(templates, err).ExecuteTemplate(w, "base", params)
case r.Method == "POST": case r.Method == "POST":
//Attempt to login // Find the user with the provided username
succ, u, err := auth.Login(r) username, password := r.FormValue("username"), r.FormValue("password")
u, err := models.GetUserByUsername(username)
if err != nil {
log.Error(err)
as.handleInvalidLogin(w, r, "Invalid Username/Password")
return
}
// Validate the user's password
err = auth.ValidatePassword(password, u.Hash)
if err != nil {
log.Error(err)
as.handleInvalidLogin(w, r, "Invalid Username/Password")
return
}
if u.AccountLocked {
as.handleInvalidLogin(w, r, "Account Locked")
return
}
u.LastLogin = time.Now().UTC()
err = models.PutUser(&u)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
// If we've logged in, save the session and redirect to the dashboard // If we've logged in, save the session and redirect to the dashboard
if succ {
session.Values["id"] = u.Id session.Values["id"] = u.Id
session.Save(r, w) session.Save(r, w)
next := "/" as.nextOrIndex(w, r)
url, err := url.Parse(r.FormValue("next"))
if err == nil {
path := url.Path
if path != "" {
next = path
}
}
http.Redirect(w, r, next, 302)
} else {
Flash(w, r, "danger", "Invalid Username/Password")
params.Flashes = session.Flashes()
session.Save(r, w)
templates := template.New("template")
_, err := templates.ParseFiles("templates/login.html", "templates/flashes.html")
if err != nil {
log.Error(err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
template.Must(templates, err).ExecuteTemplate(w, "base", params)
}
} }
} }
@ -295,9 +410,72 @@ func (as *AdminServer) Logout(w http.ResponseWriter, r *http.Request) {
delete(session.Values, "id") delete(session.Values, "id")
Flash(w, r, "success", "You have successfully logged out") Flash(w, r, "success", "You have successfully logged out")
session.Save(r, w) session.Save(r, w)
http.Redirect(w, r, "/login", 302) http.Redirect(w, r, "/login", http.StatusFound)
} }
// ResetPassword handles the password reset flow when a password change is
// required either by the Gophish system or an administrator.
//
// This handler is meant to be used when a user is required to reset their
// password, not just when they want to.
//
// This is an important distinction since in this handler we don't require
// the user to re-enter their current password, as opposed to the flow
// through the settings handler.
//
// To that end, if the user doesn't require a password change, we will
// redirect them to the settings page.
func (as *AdminServer) ResetPassword(w http.ResponseWriter, r *http.Request) {
u := ctx.Get(r, "user").(models.User)
session := ctx.Get(r, "session").(*sessions.Session)
if !u.PasswordChangeRequired {
Flash(w, r, "info", "Please reset your password through the settings page")
session.Save(r, w)
http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect)
return
}
params := newTemplateParams(r)
params.Title = "Reset Password"
switch {
case r.Method == http.MethodGet:
params.Flashes = session.Flashes()
session.Save(r, w)
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
return
case r.Method == http.MethodPost:
newPassword := r.FormValue("password")
confirmPassword := r.FormValue("confirm_password")
newHash, err := auth.ValidatePasswordChange(u.Hash, newPassword, confirmPassword)
if err != nil {
Flash(w, r, "danger", err.Error())
params.Flashes = session.Flashes()
session.Save(r, w)
w.WriteHeader(http.StatusBadRequest)
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
return
}
u.PasswordChangeRequired = false
u.Hash = newHash
if err = models.PutUser(&u); err != nil {
Flash(w, r, "danger", err.Error())
params.Flashes = session.Flashes()
session.Save(r, w)
w.WriteHeader(http.StatusInternalServerError)
getTemplate(w, "reset_password").ExecuteTemplate(w, "base", params)
return
}
// TODO: We probably want to flash a message here that the password was
// changed successfully. The problem is that when the user resets their
// password on first use, they will see two flashes on the dashboard-
// one for their password reset, and one for the "no campaigns created".
//
// The solution to this is to revamp the empty page to be more useful,
// like a wizard or something.
as.nextOrIndex(w, r)
}
}
// TODO: Make this execute the template, too
func getTemplate(w http.ResponseWriter, tmpl string) *template.Template { func getTemplate(w http.ResponseWriter, tmpl string) *template.Template {
templates := template.New("template") templates := template.New("template")
_, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html") _, err := templates.ParseFiles("templates/base.html", "templates/nav.html", "templates/"+tmpl+".html", "templates/flashes.html")

View File

@ -5,106 +5,126 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"testing"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func (s *ControllersSuite) TestLoginCSRF() { func attemptLogin(t *testing.T, ctx *testContext, client *http.Client, username, password, optionalPath string) *http.Response {
resp, err := http.PostForm(fmt.Sprintf("%s/login", s.adminServer.URL), resp, err := http.Get(fmt.Sprintf("%s/login", ctx.adminServer.URL))
if err != nil {
t.Fatalf("error requesting the /login endpoint: %v", err)
}
got := resp.StatusCode
expected := http.StatusOK
if got != expected {
t.Fatalf("invalid status code received. expected %d got %d", expected, got)
}
doc, err := goquery.NewDocumentFromResponse(resp)
if err != nil {
t.Fatalf("error parsing /login response body")
}
elem := doc.Find("input[name='csrf_token']").First()
token, ok := elem.Attr("value")
if !ok {
t.Fatal("unable to find csrf_token value in login response")
}
if client == nil {
client = &http.Client{}
}
req, err := http.NewRequest("POST", fmt.Sprintf("%s/login%s", ctx.adminServer.URL, optionalPath), strings.NewReader(url.Values{
"username": {username},
"password": {password},
"csrf_token": {token},
}.Encode()))
if err != nil {
t.Fatalf("error creating new /login request: %v", err)
}
req.Header.Set("Cookie", resp.Header.Get("Set-Cookie"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(req)
if err != nil {
t.Fatalf("error requesting the /login endpoint: %v", err)
}
return resp
}
func TestLoginCSRF(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
resp, err := http.PostForm(fmt.Sprintf("%s/login", ctx.adminServer.URL),
url.Values{ url.Values{
"username": {"admin"}, "username": {"admin"},
"password": {"gophish"}, "password": {"gophish"},
}) })
s.Equal(resp.StatusCode, http.StatusForbidden) if err != nil {
fmt.Println(err) t.Fatalf("error requesting the /login endpoint: %v", err)
} }
func (s *ControllersSuite) TestInvalidCredentials() { got := resp.StatusCode
resp, err := http.Get(fmt.Sprintf("%s/login", s.adminServer.URL)) expected := http.StatusForbidden
s.Equal(err, nil) if got != expected {
s.Equal(resp.StatusCode, http.StatusOK) t.Fatalf("invalid status code received. expected %d got %d", expected, got)
}
doc, err := goquery.NewDocumentFromResponse(resp)
s.Equal(err, nil)
elem := doc.Find("input[name='csrf_token']").First()
token, ok := elem.Attr("value")
s.Equal(ok, true)
client := &http.Client{}
req, err := http.NewRequest("POST", fmt.Sprintf("%s/login", s.adminServer.URL), strings.NewReader(url.Values{
"username": {"admin"},
"password": {"invalid"},
"csrf_token": {token},
}.Encode()))
s.Equal(err, nil)
req.Header.Set("Cookie", resp.Header.Get("Set-Cookie"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(req)
s.Equal(err, nil)
s.Equal(resp.StatusCode, http.StatusUnauthorized)
} }
func (s *ControllersSuite) TestSuccessfulLogin() { func TestInvalidCredentials(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/login", s.adminServer.URL)) ctx := setupTest(t)
s.Equal(err, nil) defer tearDown(t, ctx)
s.Equal(resp.StatusCode, http.StatusOK) resp := attemptLogin(t, ctx, nil, "admin", "bogus", "")
got := resp.StatusCode
doc, err := goquery.NewDocumentFromResponse(resp) expected := http.StatusUnauthorized
s.Equal(err, nil) if got != expected {
elem := doc.Find("input[name='csrf_token']").First() t.Fatalf("invalid status code received. expected %d got %d", expected, got)
token, ok := elem.Attr("value") }
s.Equal(ok, true)
client := &http.Client{}
req, err := http.NewRequest("POST", fmt.Sprintf("%s/login", s.adminServer.URL), strings.NewReader(url.Values{
"username": {"admin"},
"password": {"gophish"},
"csrf_token": {token},
}.Encode()))
s.Equal(err, nil)
req.Header.Set("Cookie", resp.Header.Get("Set-Cookie"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(req)
s.Equal(err, nil)
s.Equal(resp.StatusCode, http.StatusOK)
} }
func (s *ControllersSuite) TestSuccessfulRedirect() { func TestSuccessfulLogin(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
resp := attemptLogin(t, ctx, nil, "admin", "gophish", "")
got := resp.StatusCode
expected := http.StatusOK
if got != expected {
t.Fatalf("invalid status code received. expected %d got %d", expected, got)
}
}
func TestSuccessfulRedirect(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
next := "/campaigns" next := "/campaigns"
resp, err := http.Get(fmt.Sprintf("%s/login", s.adminServer.URL))
s.Equal(err, nil)
s.Equal(resp.StatusCode, http.StatusOK)
doc, err := goquery.NewDocumentFromResponse(resp)
s.Equal(err, nil)
elem := doc.Find("input[name='csrf_token']").First()
token, ok := elem.Attr("value")
s.Equal(ok, true)
client := &http.Client{ client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, }}
resp := attemptLogin(t, ctx, client, "admin", "gophish", fmt.Sprintf("?next=%s", next))
got := resp.StatusCode
expected := http.StatusFound
if got != expected {
t.Fatalf("invalid status code received. expected %d got %d", expected, got)
} }
req, err := http.NewRequest("POST", fmt.Sprintf("%s/login?next=%s", s.adminServer.URL, next), strings.NewReader(url.Values{
"username": {"admin"},
"password": {"gophish"},
"csrf_token": {token},
}.Encode()))
s.Equal(err, nil)
req.Header.Set("Cookie", resp.Header.Get("Set-Cookie"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(req)
s.Equal(err, nil)
s.Equal(resp.StatusCode, http.StatusFound)
url, err := resp.Location() url, err := resp.Location()
s.Equal(err, nil) if err != nil {
s.Equal(url.Path, next) t.Fatalf("error parsing response Location header: %v", err)
}
if url.Path != next {
t.Fatalf("unexpected Location header received. expected %s got %s", next, url.Path)
}
}
func TestAccountLocked(t *testing.T) {
ctx := setupTest(t)
defer tearDown(t, ctx)
resp := attemptLogin(t, ctx, nil, "houdini", "gophish", "")
got := resp.StatusCode
expected := http.StatusUnauthorized
if got != expected {
t.Fatalf("invalid status code received. expected %d got %d", expected, got)
}
} }

View File

@ -0,0 +1,15 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS `webhooks` (
id integer primary key auto_increment,
name varchar(255),
url varchar(1000),
secret varchar(255),
is_active boolean default 0
);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,8 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS `imap` (user_id bigint,host varchar(255),port int,username varchar(255),password varchar(255),modified_date datetime,tls boolean,enabled boolean,folder varchar(255),restrict_domain varchar(255),delete_reported_campaign_email boolean,last_login datetime,imap_freq int);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE `imap`;

View File

@ -0,0 +1,9 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE `users` ADD COLUMN password_change_required BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,7 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE `imap` ADD COLUMN ignore_cert_errors BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,6 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE `users` ADD COLUMN last_login datetime;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,7 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE `users` ADD COLUMN account_locked BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,8 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE templates ADD COLUMN envelope_sender varchar(255);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,15 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "webhooks" (
"id" integer primary key autoincrement,
"name" varchar(255),
"url" varchar(1000),
"secret" varchar(255),
"is_active" boolean default 0
);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,8 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "imap" ("user_id" bigint, "host" varchar(255), "port" integer, "username" varchar(255), "password" varchar(255), "modified_date" datetime default CURRENT_TIMESTAMP, "tls" BOOLEAN, "enabled" BOOLEAN, "folder" varchar(255), "restrict_domain" varchar(255), "delete_reported_campaign_email" BOOLEAN, "last_login" datetime, "imap_freq" integer);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE "imap";

View File

@ -0,0 +1,9 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE users ADD COLUMN password_change_required BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,7 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE imap ADD COLUMN ignore_cert_errors BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,6 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE users ADD COLUMN last_login datetime;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,6 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE users ADD COLUMN account_locked BOOLEAN;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,8 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE templates ADD COLUMN envelope_sender varchar(255);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

158
dialer/dialer.go Normal file
View File

@ -0,0 +1,158 @@
package dialer
import (
"fmt"
"net"
"syscall"
"time"
)
// RestrictedDialer is used to create a net.Dialer which restricts outbound
// connections to only allowlisted IP ranges.
type RestrictedDialer struct {
allowedHosts []*net.IPNet
}
// DefaultDialer is a global instance of a RestrictedDialer
var DefaultDialer = &RestrictedDialer{}
// SetAllowedHosts sets the list of allowed hosts or IP ranges for the default
// dialer.
func SetAllowedHosts(allowed []string) {
DefaultDialer.SetAllowedHosts(allowed)
}
// AllowedHosts returns the configured hosts that are allowed for the dialer.
func (d *RestrictedDialer) AllowedHosts() []string {
ranges := []string{}
for _, ipRange := range d.allowedHosts {
ranges = append(ranges, ipRange.String())
}
return ranges
}
// SetAllowedHosts sets the list of allowed hosts or IP ranges for the dialer.
func (d *RestrictedDialer) SetAllowedHosts(allowed []string) error {
for _, ipRange := range allowed {
// For flexibility, try to parse as an IP first since this will
// undoubtedly cause issues. If it works, then just append the
// appropriate subnet mask, then parse as CIDR
if singleIP := net.ParseIP(ipRange); singleIP != nil {
if singleIP.To4() != nil {
ipRange += "/32"
} else {
ipRange += "/128"
}
}
_, parsed, err := net.ParseCIDR(ipRange)
if err != nil {
return fmt.Errorf("provided ip range is not valid CIDR notation: %v", err)
}
d.allowedHosts = append(d.allowedHosts, parsed)
}
return nil
}
// Dialer returns a net.Dialer that restricts outbound connections to only the
// addresses allowed by the DefaultDialer.
func Dialer() *net.Dialer {
return DefaultDialer.Dialer()
}
// Dialer returns a net.Dialer that restricts outbound connections to only the
// allowed addresses over TCP.
//
// By default, since Gophish anticipates connections originating to hosts on
// the local network, we only deny access to the link-local addresses at
// 169.254.0.0/16.
//
// If hosts are provided, then Gophish blocks access to all local addresses
// except the ones provided.
//
// This implementation is based on the blog post by Andrew Ayer at
// https://www.agwa.name/blog/post/preventing_server_side_request_forgery_in_golang
func (d *RestrictedDialer) Dialer() *net.Dialer {
return &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Control: restrictedControl(d.allowedHosts),
}
}
// defaultDeny represents the list of IP ranges that we want to block unless
// explicitly overriden.
var defaultDeny = []string{
"169.254.0.0/16", // Link-local (used for VPS instance metadata)
}
// allInternal represents all internal hosts such that the only connections
// allowed are external ones.
var allInternal = []string{
"0.0.0.0/8",
"127.0.0.0/8", // IPv4 loopback
"10.0.0.0/8", // RFC1918
"100.64.0.0/10", // CGNAT
"172.16.0.0/12", // RFC1918
"169.254.0.0/16", // RFC3927 link-local
"192.88.99.0/24", // IPv6 to IPv4 Relay
"192.168.0.0/16", // RFC1918
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"224.0.0.0/4", // Multicast
"240.0.0.0/4", // Reserved
"255.255.255.255/32", // Broadcast
"::/0", // Default route
"::/128", // Unspecified address
"::1/128", // IPv6 loopback
"::ffff:0:0/96", // IPv4 mapped addresses.
"::ffff:0:0:0/96", // IPv4 translated addresses.
"fe80::/10", // IPv6 link-local
"fc00::/7", // IPv6 unique local addr
}
type dialControl = func(network, address string, c syscall.RawConn) error
type restrictedDialer struct {
*net.Dialer
allowed []string
}
func restrictedControl(allowed []*net.IPNet) dialControl {
return func(network string, address string, conn syscall.RawConn) error {
if !(network == "tcp4" || network == "tcp6") {
return fmt.Errorf("%s is not a safe network type", network)
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("%s is not a valid host/port pair: %s", address, err)
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("%s is not a valid IP address", host)
}
denyList := defaultDeny
if len(allowed) > 0 {
denyList = allInternal
}
for _, ipRange := range allowed {
if ipRange.Contains(ip) {
return nil
}
}
for _, ipRange := range denyList {
_, parsed, err := net.ParseCIDR(ipRange)
if err != nil {
return fmt.Errorf("error parsing denied range: %v", err)
}
if parsed.Contains(ip) {
return fmt.Errorf("upstream connection denied to internal host")
}
}
return nil
}
}

85
dialer/dialer_test.go Normal file
View File

@ -0,0 +1,85 @@
package dialer
import (
"fmt"
"net"
"strings"
"syscall"
"testing"
)
func TestDefaultDeny(t *testing.T) {
control := restrictedControl([]*net.IPNet{})
host := "169.254.169.254"
expected := fmt.Errorf("upstream connection denied to internal host at %s", host)
conn := new(syscall.RawConn)
got := control("tcp4", fmt.Sprintf("%s:80", host), *conn)
if !strings.Contains(got.Error(), "upstream connection denied") {
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got)
}
}
func TestDefaultAllow(t *testing.T) {
control := restrictedControl([]*net.IPNet{})
host := "1.1.1.1"
conn := new(syscall.RawConn)
got := control("tcp4", fmt.Sprintf("%s:80", host), *conn)
if got != nil {
t.Fatalf("error dialing allowed host. got %v", got)
}
}
func TestCustomAllow(t *testing.T) {
host := "127.0.0.1"
_, ipRange, _ := net.ParseCIDR(fmt.Sprintf("%s/32", host))
allowed := []*net.IPNet{ipRange}
control := restrictedControl(allowed)
conn := new(syscall.RawConn)
got := control("tcp4", fmt.Sprintf("%s:80", host), *conn)
if got != nil {
t.Fatalf("error dialing allowed host. got %v", got)
}
}
func TestCustomDeny(t *testing.T) {
host := "127.0.0.1"
_, ipRange, _ := net.ParseCIDR(fmt.Sprintf("%s/32", host))
allowed := []*net.IPNet{ipRange}
control := restrictedControl(allowed)
conn := new(syscall.RawConn)
expected := fmt.Errorf("upstream connection denied to internal host at %s", host)
got := control("tcp4", "192.168.1.2:80", *conn)
if !strings.Contains(got.Error(), "upstream connection denied") {
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got)
}
}
func TestSingleIP(t *testing.T) {
orig := DefaultDialer.AllowedHosts()
host := "127.0.0.1"
DefaultDialer.SetAllowedHosts([]string{host})
control := DefaultDialer.Dialer().Control
conn := new(syscall.RawConn)
expected := fmt.Errorf("upstream connection denied to internal host at %s", host)
got := control("tcp4", "192.168.1.2:80", *conn)
if !strings.Contains(got.Error(), "upstream connection denied") {
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got)
}
host = "::1"
DefaultDialer.SetAllowedHosts([]string{host})
control = DefaultDialer.Dialer().Control
conn = new(syscall.RawConn)
expected = fmt.Errorf("upstream connection denied to internal host at %s", host)
got = control("tcp4", "192.168.1.2:80", *conn)
if !strings.Contains(got.Error(), "upstream connection denied") {
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got)
}
// Test an allowed connection
got = control("tcp4", fmt.Sprintf("[%s]:80", host), *conn)
if got != nil {
t.Fatalf("error dialing allowed host. got %v", got)
}
DefaultDialer.SetAllowedHosts(orig)
}

View File

@ -5,25 +5,31 @@ if [ -n "${ADMIN_LISTEN_URL+set}" ] ; then
jq -r \ jq -r \
--arg ADMIN_LISTEN_URL "${ADMIN_LISTEN_URL}" \ --arg ADMIN_LISTEN_URL "${ADMIN_LISTEN_URL}" \
'.admin_server.listen_url = $ADMIN_LISTEN_URL' config.json > config.json.tmp && \ '.admin_server.listen_url = $ADMIN_LISTEN_URL' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
if [ -n "${ADMIN_USE_TLS+set}" ] ; then if [ -n "${ADMIN_USE_TLS+set}" ] ; then
jq -r \ jq -r \
--argjson ADMIN_USE_TLS "${ADMIN_USE_TLS}" \ --argjson ADMIN_USE_TLS "${ADMIN_USE_TLS}" \
'.admin_server.use_tls = $ADMIN_USE_TLS' config.json > config.json.tmp && \ '.admin_server.use_tls = $ADMIN_USE_TLS' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
if [ -n "${ADMIN_CERT_PATH+set}" ] ; then if [ -n "${ADMIN_CERT_PATH+set}" ] ; then
jq -r \ jq -r \
--arg ADMIN_CERT_PATH "${ADMIN_CERT_PATH}" \ --arg ADMIN_CERT_PATH "${ADMIN_CERT_PATH}" \
'.admin_server.cert_path = $ADMIN_CERT_PATH' config.json > config.json.tmp && \ '.admin_server.cert_path = $ADMIN_CERT_PATH' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
if [ -n "${ADMIN_KEY_PATH+set}" ] ; then if [ -n "${ADMIN_KEY_PATH+set}" ] ; then
jq -r \ jq -r \
--arg ADMIN_KEY_PATH "${ADMIN_KEY_PATH}" \ --arg ADMIN_KEY_PATH "${ADMIN_KEY_PATH}" \
'.admin_server.key_path = $ADMIN_KEY_PATH' config.json > config.json.tmp && \ '.admin_server.key_path = $ADMIN_KEY_PATH' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi
if [ -n "${ADMIN_TRUSTED_ORIGINS+set}" ] ; then
jq -r \
--arg ADMIN_TRUSTED_ORIGINS "${ADMIN_TRUSTED_ORIGINS}" \
'.admin_server.trusted_origins = ($ADMIN_TRUSTED_ORIGINS|split(","))' config.json > config.json.tmp && \
cat config.json.tmp > config.json
fi fi
# set config for phish_server # set config for phish_server
@ -31,25 +37,25 @@ if [ -n "${PHISH_LISTEN_URL+set}" ] ; then
jq -r \ jq -r \
--arg PHISH_LISTEN_URL "${PHISH_LISTEN_URL}" \ --arg PHISH_LISTEN_URL "${PHISH_LISTEN_URL}" \
'.phish_server.listen_url = $PHISH_LISTEN_URL' config.json > config.json.tmp && \ '.phish_server.listen_url = $PHISH_LISTEN_URL' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
if [ -n "${PHISH_USE_TLS+set}" ] ; then if [ -n "${PHISH_USE_TLS+set}" ] ; then
jq -r \ jq -r \
--argjson PHISH_USE_TLS "${PHISH_USE_TLS}" \ --argjson PHISH_USE_TLS "${PHISH_USE_TLS}" \
'.phish_server.use_tls = $PHISH_USE_TLS' config.json > config.json.tmp && \ '.phish_server.use_tls = $PHISH_USE_TLS' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
if [ -n "${PHISH_CERT_PATH+set}" ] ; then if [ -n "${PHISH_CERT_PATH+set}" ] ; then
jq -r \ jq -r \
--arg PHISH_CERT_PATH "${PHISH_CERT_PATH}" \ --arg PHISH_CERT_PATH "${PHISH_CERT_PATH}" \
'.phish_server.cert_path = $PHISH_CERT_PATH' config.json > config.json.tmp && \ '.phish_server.cert_path = $PHISH_CERT_PATH' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
if [ -n "${PHISH_KEY_PATH+set}" ] ; then if [ -n "${PHISH_KEY_PATH+set}" ] ; then
jq -r \ jq -r \
--arg PHISH_KEY_PATH "${PHISH_KEY_PATH}" \ --arg PHISH_KEY_PATH "${PHISH_KEY_PATH}" \
'.phish_server.key_path = $PHISH_KEY_PATH' config.json > config.json.tmp && \ '.phish_server.key_path = $PHISH_KEY_PATH' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
# set contact_address # set contact_address
@ -57,9 +63,25 @@ if [ -n "${CONTACT_ADDRESS+set}" ] ; then
jq -r \ jq -r \
--arg CONTACT_ADDRESS "${CONTACT_ADDRESS}" \ --arg CONTACT_ADDRESS "${CONTACT_ADDRESS}" \
'.contact_address = $CONTACT_ADDRESS' config.json > config.json.tmp && \ '.contact_address = $CONTACT_ADDRESS' config.json > config.json.tmp && \
mv config.json.tmp config.json cat config.json.tmp > config.json
fi fi
# db_name has to be changed to mysql for mysql connection to work
if [ -n "${DB_NAME+set}" ] ; then
jq -r \
--arg DB_NAME "${DB_NAME}" \
'.db_name = $DB_NAME' config.json > config.json.tmp && \
cat config.json.tmp > config.json
fi
if [ -n "${DB_FILE_PATH+set}" ] ; then
jq -r \
--arg DB_FILE_PATH "${DB_FILE_PATH}" \
'.db_path = $DB_FILE_PATH' config.json > config.json.tmp && \
cat config.json.tmp > config.json
fi
echo "Runtime configuration: "
cat config.json cat config.json
# start gophish # start gophish

34
go.mod Normal file
View File

@ -0,0 +1,34 @@
module github.com/gophish/gophish
go 1.13
require (
bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c
github.com/NYTimes/gziphandler v1.1.1
github.com/PuerkitoBio/goquery v1.5.0
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
github.com/emersion/go-imap v1.0.4
github.com/emersion/go-message v0.12.0
github.com/go-sql-driver/mysql v1.5.0
github.com/gophish/gomail v0.0.0-20200818021916-1f6d0dfd512e
github.com/gorilla/context v1.1.1
github.com/gorilla/csrf v1.6.2
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.0
github.com/jinzhu/gorm v1.9.12
github.com/jordan-wright/email v4.0.1-0.20200824153738-3f5bafa1cd84+incompatible
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/oschwald/maxminddb-golang v1.6.0
github.com/sirupsen/logrus v1.4.2
github.com/ziutek/mymysql v1.5.4 // indirect
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405
)

115
go.sum Normal file
View File

@ -0,0 +1,115 @@
bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c h1:bkb2NMGo3/Du52wvYj9Whth5KZfMV6d3O0Vbr3nz/UE=
bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/emersion/go-imap v1.0.4 h1:uiCAIHM6Z5Jwkma1zdNDWWXxSCqb+/xHBkHflD7XBro=
github.com/emersion/go-imap v1.0.4/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU=
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.12.0 h1:mZnv35eZ6lB6EftTQBgYXspOH0FQdhpFhSUhA9i6/Zg=
github.com/emersion/go-message v0.12.0/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs=
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gophish/gomail v0.0.0-20200818021916-1f6d0dfd512e h1:URNpXdOxXAfuZ8wsr/DY27KTffVenKDjtNVAEwcR2Oo=
github.com/gophish/gomail v0.0.0-20200818021916-1f6d0dfd512e/go.mod h1:JGlHttcLdDp3F4g8bPHqqQnUUDuB3poB4zLXozQ0xCY=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/csrf v1.6.2 h1:QqQ/OWwuFp4jMKgBFAzJVW3FMULdyUW7JoM4pEWuqKg=
github.com/gorilla/csrf v1.6.2/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q=
github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jordan-wright/email v4.0.1-0.20200824153738-3f5bafa1cd84+incompatible h1:d60x4RsAHk/UX/0OT8Gc6D7scVvhBbEANpTAWrDhA/I=
github.com/jordan-wright/email v4.0.1-0.20200824153738-3f5bafa1cd84+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f h1:bYVTBvVHcAYDkH8hyVMRUW7J2mYQNNSmQPXGadYd1nY=
github.com/jordan-wright/unindexed v0.0.0-20181209214434-78fa79113c0f/go.mod h1:eRt05O5haIXGKGodWjpQ2xdgBHTE7hg/pzsukNi9IRA=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 h1:mkl3tvPHIuPaWsLtmHTybJeoVEW7cbePK73Ir8VtruA=
github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c=
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/oschwald/maxminddb-golang v1.6.0 h1:KAJSjdHQ8Kv45nFIbtoLGrGWqHFajOIm7skTyz/+Dls=
github.com/oschwald/maxminddb-golang v1.6.0/go.mod h1:DUJFucBg2cvqx42YmDa/+xHvb0elJtOm3o4aFQ/nb/w=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d h1:9FCpayM9Egr1baVnV1SX0H87m+XB0B8S0hAMi99X/3U=
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -26,7 +26,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
*/ */
import ( import (
"fmt"
"io/ioutil" "io/ioutil"
"net/http"
"os" "os"
"os/signal" "os/signal"
@ -34,14 +36,25 @@ import (
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
"github.com/gophish/gophish/controllers" "github.com/gophish/gophish/controllers"
"github.com/gophish/gophish/dialer"
"github.com/gophish/gophish/imap"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/middleware" "github.com/gophish/gophish/middleware"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/webhook"
)
const (
modeAll string = "all"
modeAdmin string = "admin"
modePhish string = "phish"
) )
var ( var (
configPath = kingpin.Flag("config", "Location of config.json.").Default("./config.json").String() configPath = kingpin.Flag("config", "Location of config.json.").Default("./config.json").String()
disableMailer = kingpin.Flag("disable-mailer", "Disable the mailer (for use with multi-system deployments)").Bool() disableMailer = kingpin.Flag("disable-mailer", "Disable the mailer (for use with multi-system deployments)").Bool()
mode = kingpin.Flag("mode", fmt.Sprintf("Run the binary in one of the modes (%s, %s or %s)", modeAll, modeAdmin, modePhish)).
Default("all").Enum(modeAll, modeAdmin, modePhish)
) )
func main() { func main() {
@ -69,7 +82,14 @@ func main() {
} }
config.Version = string(version) config.Version = string(version)
err = log.Setup(conf) // Configure our various upstream clients to make sure that we restrict
// outbound connections as needed.
dialer.SetAllowedHosts(conf.AdminConf.AllowedInternalHosts)
webhook.SetTransport(&http.Transport{
DialContext: dialer.Dialer().DialContext,
})
err = log.Setup(conf.Logging)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -80,6 +100,7 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Unlock any maillogs that may have been locked for processing // Unlock any maillogs that may have been locked for processing
// when Gophish was last shutdown. // when Gophish was last shutdown.
err = models.UnlockAllMailLogs() err = models.UnlockAllMailLogs()
@ -99,14 +120,26 @@ func main() {
phishConfig := conf.PhishConf phishConfig := conf.PhishConf
phishServer := controllers.NewPhishingServer(phishConfig) phishServer := controllers.NewPhishingServer(phishConfig)
imapMonitor := imap.NewMonitor()
if *mode == "admin" || *mode == "all" {
go adminServer.Start() go adminServer.Start()
go imapMonitor.Start()
}
if *mode == "phish" || *mode == "all" {
go phishServer.Start() go phishServer.Start()
}
// Handle graceful shutdown // Handle graceful shutdown
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt) signal.Notify(c, os.Interrupt)
<-c <-c
log.Info("CTRL+C Received... Gracefully shutting down servers") log.Info("CTRL+C Received... Gracefully shutting down servers")
if *mode == modeAdmin || *mode == modeAll {
adminServer.Shutdown() adminServer.Shutdown()
imapMonitor.Shutdown()
}
if *mode == modePhish || *mode == modeAll {
phishServer.Shutdown() phishServer.Shutdown()
} }
}

View File

@ -7,7 +7,7 @@
var gulp = require('gulp'), var gulp = require('gulp'),
rename = require('gulp-rename'), rename = require('gulp-rename'),
concat = require('gulp-concat'), concat = require('gulp-concat'),
uglify = require('gulp-uglify'), uglify = require('gulp-uglify-es').default,
cleanCSS = require('gulp-clean-css'), cleanCSS = require('gulp-clean-css'),
babel = require('gulp-babel'), babel = require('gulp-babel'),
@ -61,6 +61,9 @@ scripts = function () {
app_directory + 'settings.js', app_directory + 'settings.js',
app_directory + 'templates.js', app_directory + 'templates.js',
app_directory + 'gophish.js', app_directory + 'gophish.js',
app_directory + 'users.js',
app_directory + 'webhooks.js',
app_directory + 'passwords.js'
]) ])
.pipe(rename({ .pipe(rename({
suffix: '.min' suffix: '.min'

211
imap/imap.go Normal file
View File

@ -0,0 +1,211 @@
package imap
import (
"bytes"
"crypto/tls"
"fmt"
"regexp"
"strconv"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-message/charset"
"github.com/gophish/gophish/dialer"
log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models"
"github.com/jordan-wright/email"
)
// Client interface for IMAP interactions
type Client interface {
Login(username, password string) (cmd *imap.Command, err error)
Logout(timeout time.Duration) (cmd *imap.Command, err error)
Select(name string, readOnly bool) (mbox *imap.MailboxStatus, err error)
Store(seq *imap.SeqSet, item imap.StoreItem, value interface{}, ch chan *imap.Message) (err error)
Fetch(seqset *imap.SeqSet, items []imap.FetchItem, ch chan *imap.Message) (err error)
}
// Email represents an email.Email with an included IMAP Sequence Number
type Email struct {
SeqNum uint32 `json:"seqnum"`
*email.Email
}
// Mailbox holds onto the credentials and other information
// needed for connecting to an IMAP server.
type Mailbox struct {
Host string
TLS bool
IgnoreCertErrors bool
User string
Pwd string
Folder string
// Read only mode, false (original logic) if not initialized
ReadOnly bool
}
// Validate validates supplied IMAP model by connecting to the server
func Validate(s *models.IMAP) error {
err := s.Validate()
if err != nil {
log.Error(err)
return err
}
s.Host = s.Host + ":" + strconv.Itoa(int(s.Port)) // Append port
mailServer := Mailbox{
Host: s.Host,
TLS: s.TLS,
IgnoreCertErrors: s.IgnoreCertErrors,
User: s.Username,
Pwd: s.Password,
Folder: s.Folder}
imapClient, err := mailServer.newClient()
if err != nil {
log.Error(err.Error())
} else {
imapClient.Logout()
}
return err
}
// MarkAsUnread will set the UNSEEN flag on a supplied slice of SeqNums
func (mbox *Mailbox) MarkAsUnread(seqs []uint32) error {
imapClient, err := mbox.newClient()
if err != nil {
return err
}
defer imapClient.Logout()
seqSet := new(imap.SeqSet)
seqSet.AddNum(seqs...)
item := imap.FormatFlagsOp(imap.RemoveFlags, true)
err = imapClient.Store(seqSet, item, imap.SeenFlag, nil)
if err != nil {
return err
}
return nil
}
// DeleteEmails will delete emails from the supplied slice of SeqNums
func (mbox *Mailbox) DeleteEmails(seqs []uint32) error {
imapClient, err := mbox.newClient()
if err != nil {
return err
}
defer imapClient.Logout()
seqSet := new(imap.SeqSet)
seqSet.AddNum(seqs...)
item := imap.FormatFlagsOp(imap.AddFlags, true)
err = imapClient.Store(seqSet, item, imap.DeletedFlag, nil)
if err != nil {
return err
}
return nil
}
// GetUnread will find all unread emails in the folder and return them as a list.
func (mbox *Mailbox) GetUnread(markAsRead, delete bool) ([]Email, error) {
imap.CharsetReader = charset.Reader
var emails []Email
imapClient, err := mbox.newClient()
if err != nil {
return emails, fmt.Errorf("failed to create IMAP connection: %s", err)
}
defer imapClient.Logout()
// Search for unread emails
criteria := imap.NewSearchCriteria()
criteria.WithoutFlags = []string{imap.SeenFlag}
seqs, err := imapClient.Search(criteria)
if err != nil {
return emails, err
}
if len(seqs) == 0 {
return emails, nil
}
seqset := new(imap.SeqSet)
seqset.AddNum(seqs...)
section := &imap.BodySectionName{}
items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchInternalDate, section.FetchItem()}
messages := make(chan *imap.Message)
go func() {
if err := imapClient.Fetch(seqset, items, messages); err != nil {
log.Error("Error fetching emails: ", err.Error()) // TODO: How to handle this, need to propogate error out
}
}()
// Step through each email
for msg := range messages {
// Extract raw message body. I can't find a better way to do this with the emersion library
var em *email.Email
var buf []byte
for _, value := range msg.Body {
buf = make([]byte, value.Len())
value.Read(buf)
break // There should only ever be one item in this map, but I'm not 100% sure
}
//Remove CR characters, see https://github.com/jordan-wright/email/issues/106
tmp := string(buf)
re := regexp.MustCompile(`\r`)
tmp = re.ReplaceAllString(tmp, "")
buf = []byte(tmp)
rawBodyStream := bytes.NewReader(buf)
em, err = email.NewEmailFromReader(rawBodyStream) // Parse with @jordanwright's library
if err != nil {
return emails, err
}
emtmp := Email{Email: em, SeqNum: msg.SeqNum} // Not sure why msg.Uid is always 0, so swapped to sequence numbers
emails = append(emails, emtmp)
}
return emails, nil
}
// newClient will initiate a new IMAP connection with the given creds.
func (mbox *Mailbox) newClient() (*client.Client, error) {
var imapClient *client.Client
var err error
restrictedDialer := dialer.Dialer()
if mbox.TLS {
config := new(tls.Config)
config.InsecureSkipVerify = mbox.IgnoreCertErrors
imapClient, err = client.DialWithDialerTLS(restrictedDialer, mbox.Host, config)
} else {
imapClient, err = client.DialWithDialer(restrictedDialer, mbox.Host)
}
if err != nil {
return imapClient, err
}
err = imapClient.Login(mbox.User, mbox.Pwd)
if err != nil {
return imapClient, err
}
_, err = imapClient.Select(mbox.Folder, mbox.ReadOnly)
if err != nil {
return imapClient, err
}
return imapClient, nil
}

238
imap/monitor.go Normal file
View File

@ -0,0 +1,238 @@
package imap
/* TODO:
* - Have a counter per config for number of consecutive login errors and backoff (e.g if supplied creds are incorrect)
* - Have a DB field "last_login_error" if last login failed
* - DB counter for non-campaign emails that the admin should investigate
* - Add field to User for numner of non-campaign emails reported
*/
import (
"bytes"
"context"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
log "github.com/gophish/gophish/logger"
"github.com/jordan-wright/email"
"github.com/gophish/gophish/models"
)
// Pattern for GoPhish emails e.g ?rid=AbC1234
// We include the optional quoted-printable 3D at the front, just in case decoding fails. e.g ?rid=3DAbC1234
// We also include alternative URL encoded representations of '=' and '?' to handle Microsoft ATP URLs e.g %3Frid%3DAbC1234
var goPhishRegex = regexp.MustCompile("((\\?|%3F)rid(=|%3D)(3D)?([A-Za-z0-9]{7}))")
// Monitor is a worker that monitors IMAP servers for reported campaign emails
type Monitor struct {
cancel func()
}
// Monitor.start() checks for campaign emails
// As each account can have its own polling frequency set we need to run one Go routine for
// each, as well as keeping an eye on newly created user accounts.
func (im *Monitor) start(ctx context.Context) {
usermap := make(map[int64]int) // Keep track of running go routines, one per user. We assume incrementing non-repeating UIDs (for the case where users are deleted and re-added).
for {
select {
case <-ctx.Done():
return
default:
dbusers, err := models.GetUsers() //Slice of all user ids. Each user gets their own IMAP monitor routine.
if err != nil {
log.Error(err)
break
}
for _, dbuser := range dbusers {
if _, ok := usermap[dbuser.Id]; !ok { // If we don't currently have a running Go routine for this user, start one.
log.Info("Starting new IMAP monitor for user ", dbuser.Username)
usermap[dbuser.Id] = 1
go monitor(dbuser.Id, ctx)
}
}
time.Sleep(10 * time.Second) // Every ten seconds we check if a new user has been created
}
}
}
// monitor will continuously login to the IMAP settings associated to the supplied user id (if the user account has IMAP settings, and they're enabled.)
// It also verifies the user account exists, and returns if not (for the case of a user being deleted).
func monitor(uid int64, ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// 1. Check if user exists, if not, return.
_, err := models.GetUser(uid)
if err != nil { // Not sure if there's a better way to determine user existence via id.
log.Info("User ", uid, " seems to have been deleted. Stopping IMAP monitor for this user.")
return
}
// 2. Check if user has IMAP settings.
imapSettings, err := models.GetIMAP(uid)
if err != nil {
log.Error(err)
break
}
if len(imapSettings) > 0 {
im := imapSettings[0]
// 3. Check if IMAP is enabled
if im.Enabled {
log.Debug("Checking IMAP for user ", uid, ": ", im.Username, " -> ", im.Host)
checkForNewEmails(im)
time.Sleep((time.Duration(im.IMAPFreq) - 10) * time.Second) // Subtract 10 to compensate for the default sleep of 10 at the bottom
}
}
}
time.Sleep(10 * time.Second)
}
}
// NewMonitor returns a new instance of imap.Monitor
func NewMonitor() *Monitor {
im := &Monitor{}
return im
}
// Start launches the IMAP campaign monitor
func (im *Monitor) Start() error {
log.Info("Starting IMAP monitor manager")
ctx, cancel := context.WithCancel(context.Background()) // ctx is the derivedContext
im.cancel = cancel
go im.start(ctx)
return nil
}
// Shutdown attempts to gracefully shutdown the IMAP monitor.
func (im *Monitor) Shutdown() error {
log.Info("Shutting down IMAP monitor manager")
im.cancel()
return nil
}
// checkForNewEmails logs into an IMAP account and checks unread emails
// for the rid campaign identifier.
func checkForNewEmails(im models.IMAP) {
im.Host = im.Host + ":" + strconv.Itoa(int(im.Port)) // Append port
mailServer := Mailbox{
Host: im.Host,
TLS: im.TLS,
IgnoreCertErrors: im.IgnoreCertErrors,
User: im.Username,
Pwd: im.Password,
Folder: im.Folder,
}
msgs, err := mailServer.GetUnread(true, false)
if err != nil {
log.Error(err)
return
}
// Update last_succesful_login here via im.Host
err = models.SuccessfulLogin(&im)
if len(msgs) > 0 {
log.Debugf("%d new emails for %s", len(msgs), im.Username)
var reportingFailed []uint32 // SeqNums of emails that were unable to be reported to phishing server, mark as unread
var deleteEmails []uint32 // SeqNums of campaign emails. If DeleteReportedCampaignEmail is true, we will delete these
for _, m := range msgs {
// Check if sender is from company's domain, if enabled. TODO: Make this an IMAP filter
if im.RestrictDomain != "" { // e.g domainResitct = widgets.com
splitEmail := strings.Split(m.Email.From, "@")
senderDomain := splitEmail[len(splitEmail)-1]
if senderDomain != im.RestrictDomain {
log.Debug("Ignoring email as not from company domain: ", senderDomain)
continue
}
}
rids, err := matchEmail(m.Email) // Search email Text, HTML, and each attachment for rid parameters
if err != nil {
log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error())
continue
}
if len(rids) < 1 {
// In the future this should be an alert in Gophish
log.Infof("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.", m.Email.From, m.Email.Subject)
}
for rid := range rids {
log.Infof("User '%s' reported email with rid %s", m.Email.From, rid)
result, err := models.GetResult(rid)
if err != nil {
log.Error("Error reporting GoPhish email with rid ", rid, ": ", err.Error())
reportingFailed = append(reportingFailed, m.SeqNum)
continue
}
err = result.HandleEmailReport(models.EventDetails{})
if err != nil {
log.Error("Error updating GoPhish email with rid ", rid, ": ", err.Error())
continue
}
if im.DeleteReportedCampaignEmail {
deleteEmails = append(deleteEmails, m.SeqNum)
}
}
}
// Check if any emails were unable to be reported, so we can mark them as unread
if len(reportingFailed) > 0 {
log.Debugf("Marking %d emails as unread as failed to report", len(reportingFailed))
err := mailServer.MarkAsUnread(reportingFailed) // Set emails as unread that we failed to report to GoPhish
if err != nil {
log.Error("Unable to mark emails as unread: ", err.Error())
}
}
// If the DeleteReportedCampaignEmail flag is set, delete reported Gophish campaign emails
if len(deleteEmails) > 0 {
log.Debugf("Deleting %d campaign emails", len(deleteEmails))
err := mailServer.DeleteEmails(deleteEmails) // Delete GoPhish campaign emails.
if err != nil {
log.Error("Failed to delete emails: ", err.Error())
}
}
} else {
log.Debug("No new emails for ", im.Username)
}
}
func checkRIDs(em *email.Email, rids map[string]bool) {
// Check Text and HTML
emailContent := string(em.Text) + string(em.HTML)
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
newrid := r[len(r)-1]
if !rids[newrid] {
rids[newrid] = true
}
}
}
// returns a slice of gophish rid paramters found in the email HTML, Text, and attachments
func matchEmail(em *email.Email) (map[string]bool, error) {
rids := make(map[string]bool)
checkRIDs(em, rids)
// Next check each attachment
for _, a := range em.Attachments {
ext := filepath.Ext(a.Filename)
if a.Header.Get("Content-Type") == "message/rfc822" || ext == ".eml" {
// Let's decode the email
rawBodyStream := bytes.NewReader(a.Content)
attachmentEmail, err := email.NewEmailFromReader(rawBodyStream)
if err != nil {
return rids, err
}
checkRIDs(attachmentEmail, rids)
}
}
return rids, nil
}

View File

@ -1,10 +1,10 @@
package logger package logger
import ( import (
"errors"
"io" "io"
"os" "os"
"github.com/gophish/gophish/config"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -12,16 +12,34 @@ import (
// It is exported here for use with gorm. // It is exported here for use with gorm.
var Logger *logrus.Logger var Logger *logrus.Logger
// ErrInvalidLevel is returned when an invalid log level is given in the config
var ErrInvalidLevel = errors.New("invalid log level")
// Config represents configuration details for logging.
type Config struct {
Filename string `json:"filename"`
Level string `json:"level"`
}
func init() { func init() {
Logger = logrus.New() Logger = logrus.New()
Logger.Formatter = &logrus.TextFormatter{DisableColors: true} Logger.Formatter = &logrus.TextFormatter{DisableColors: true}
} }
// Setup configures the logger based on options in the config.json. // Setup configures the logger based on options in the config.json.
func Setup(conf *config.Config) error { func Setup(config *Config) error {
Logger.SetLevel(logrus.InfoLevel) var err error
// Set up logging level
level := logrus.InfoLevel
if config.Level != "" {
level, err = logrus.ParseLevel(config.Level)
if err != nil {
return err
}
}
Logger.SetLevel(level)
// Set up logging to a file if specified in the config // Set up logging to a file if specified in the config
logFile := conf.Logging.Filename logFile := config.Filename
if logFile != "" { if logFile != "" {
f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil { if err != nil {

26
logger/logger_test.go Normal file
View File

@ -0,0 +1,26 @@
package logger
import "testing"
import "github.com/sirupsen/logrus"
func TestLogLevel(t *testing.T) {
tests := map[string]logrus.Level{
"": logrus.InfoLevel,
"debug": logrus.DebugLevel,
"info": logrus.InfoLevel,
"error": logrus.ErrorLevel,
"fatal": logrus.FatalLevel,
}
config := &Config{}
for level, expected := range tests {
config.Level = level
err := Setup(config)
if err != nil {
t.Fatalf("error setting logging level %v", err)
}
if Logger.Level != expected {
t.Fatalf("invalid logging level. expected %v got %v", expected, Logger.Level)
}
}
}

View File

@ -55,6 +55,7 @@ type Mail interface {
Success() error Success() error
Generate(msg *gomail.Message) error Generate(msg *gomail.Message) error
GetDialer() (Dialer, error) GetDialer() (Dialer, error)
GetSmtpFrom() (string, error)
} }
// MailWorker is the worker that receives slices of emails // MailWorker is the worker that receives slices of emails
@ -160,7 +161,14 @@ func sendMail(ctx context.Context, dialer Dialer, ms []Mail) {
m.Error(err) m.Error(err)
continue continue
} }
err = gomail.Send(sender, message)
smtp_from, err := m.GetSmtpFrom()
if err != nil {
m.Error(err)
continue
}
err = gomail.SendCustomFrom(sender, smtp_from, message)
if err != nil { if err != nil {
if te, ok := err.(*textproto.Error); ok { if te, ok := err.(*textproto.Error); ok {
switch { switch {
@ -215,6 +223,8 @@ func sendMail(ctx context.Context, dialer Dialer, ms []Mail) {
} }
} }
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"smtp_from": smtp_from,
"envelope_from": message.GetHeader("From")[0],
"email": message.GetHeader("To")[0], "email": message.GetHeader("To")[0],
}).Info("Email sent") }).Info("Email sent")
m.Success() m.Success()

View File

@ -8,14 +8,8 @@ import (
"net/textproto" "net/textproto"
"reflect" "reflect"
"testing" "testing"
"github.com/stretchr/testify/suite"
) )
type MailerSuite struct {
suite.Suite
}
func generateMessages(dialer Dialer) []Mail { func generateMessages(dialer Dialer) []Mail {
to := []string{"to@example.com"} to := []string{"to@example.com"}
@ -47,30 +41,30 @@ func newMockErrorSender(err error) *mockSender {
return sender return sender
} }
func (ms *MailerSuite) TestDialHost() { func TestDialHost(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
md := newMockDialer() md := newMockDialer()
md.setDial(md.unreachableDial) md.setDial(md.unreachableDial)
_, err := dialHost(ctx, md) _, err := dialHost(ctx, md)
if _, ok := err.(*ErrMaxConnectAttempts); !ok { if _, ok := err.(*ErrMaxConnectAttempts); !ok {
ms.T().Fatalf("Didn't receive expected ErrMaxConnectAttempts. Got: %s", err) t.Fatalf("Didn't receive expected ErrMaxConnectAttempts. Got: %s", err)
} }
e := err.(*ErrMaxConnectAttempts) e := err.(*ErrMaxConnectAttempts)
if e.underlyingError != errHostUnreachable { if e.underlyingError != errHostUnreachable {
ms.T().Fatalf("Got invalid underlying error. Expected %s Got %s\n", e.underlyingError, errHostUnreachable) t.Fatalf("Got invalid underlying error. Expected %s Got %s\n", e.underlyingError, errHostUnreachable)
} }
if md.dialCount != MaxReconnectAttempts { if md.dialCount != MaxReconnectAttempts {
ms.T().Fatalf("Unexpected number of reconnect attempts. Expected %d, Got %d", MaxReconnectAttempts, md.dialCount) t.Fatalf("Unexpected number of reconnect attempts. Expected %d, Got %d", MaxReconnectAttempts, md.dialCount)
} }
md.setDial(md.defaultDial) md.setDial(md.defaultDial)
_, err = dialHost(ctx, md) _, err = dialHost(ctx, md)
if err != nil { if err != nil {
ms.T().Fatalf("Unexpected error when dialing the mock host: %s", err) t.Fatalf("Unexpected error when dialing the mock host: %s", err)
} }
} }
func (ms *MailerSuite) TestMailWorkerStart() { func TestMailWorkerStart(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -97,16 +91,16 @@ func (ms *MailerSuite) TestMailWorkerStart() {
got = append(got, message) got = append(got, message)
original := messages[idx].(*mockMessage) original := messages[idx].(*mockMessage)
if original.from != message.from { if original.from != message.from {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", original.from, message.from) t.Fatalf("Invalid message received. Expected %s, Got %s", original.from, message.from)
} }
idx++ idx++
} }
if len(got) != len(messages) { if len(got) != len(messages) {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), len(messages)) t.Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), len(messages))
} }
} }
func (ms *MailerSuite) TestBackoff() { func TestBackoff(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -139,28 +133,28 @@ func (ms *MailerSuite) TestBackoff() {
// Check that we only sent one message // Check that we only sent one message
expectedCount := 1 expectedCount := 1
if len(got) != expectedCount { if len(got) != expectedCount {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount) t.Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount)
} }
// Check that it's the correct message // Check that it's the correct message
originalFrom := messages[1].(*mockMessage).from originalFrom := messages[1].(*mockMessage).from
if got[0].from != originalFrom { if got[0].from != originalFrom {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from) t.Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from)
} }
// Check that the first message performed a backoff // Check that the first message performed a backoff
backoffCount := messages[0].(*mockMessage).backoffCount backoffCount := messages[0].(*mockMessage).backoffCount
if backoffCount != expectedCount { if backoffCount != expectedCount {
ms.T().Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount) t.Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount)
} }
// Check that there was a reset performed on the sender // Check that there was a reset performed on the sender
if sender.resetCount != expectedCount { if sender.resetCount != expectedCount {
ms.T().Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount) t.Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount)
} }
} }
func (ms *MailerSuite) TestPermError() { func TestPermError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -193,13 +187,13 @@ func (ms *MailerSuite) TestPermError() {
// Check that we only sent one message // Check that we only sent one message
expectedCount := 1 expectedCount := 1
if len(got) != expectedCount { if len(got) != expectedCount {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount) t.Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount)
} }
// Check that it's the correct message // Check that it's the correct message
originalFrom := messages[1].(*mockMessage).from originalFrom := messages[1].(*mockMessage).from
if got[0].from != originalFrom { if got[0].from != originalFrom {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from) t.Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from)
} }
message := messages[0].(*mockMessage) message := messages[0].(*mockMessage)
@ -208,21 +202,21 @@ func (ms *MailerSuite) TestPermError() {
expectedBackoffCount := 0 expectedBackoffCount := 0
backoffCount := message.backoffCount backoffCount := message.backoffCount
if backoffCount != expectedBackoffCount { if backoffCount != expectedBackoffCount {
ms.T().Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount) t.Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount)
} }
// Check that there was a reset performed on the sender // Check that there was a reset performed on the sender
if sender.resetCount != expectedCount { if sender.resetCount != expectedCount {
ms.T().Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount) t.Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount)
} }
// Check that the email errored out appropriately // Check that the email errored out appropriately
if !reflect.DeepEqual(message.err, expectedError) { if !reflect.DeepEqual(message.err, expectedError) {
ms.T().Fatalf("Did not received expected error. Got %#v\nExpected %#v", message.err, expectedError) t.Fatalf("Did not received expected error. Got %#v\nExpected %#v", message.err, expectedError)
} }
} }
func (ms *MailerSuite) TestUnknownError() { func TestUnknownError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -252,13 +246,13 @@ func (ms *MailerSuite) TestUnknownError() {
// Check that we only sent one message // Check that we only sent one message
expectedCount := 1 expectedCount := 1
if len(got) != expectedCount { if len(got) != expectedCount {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount) t.Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount)
} }
// Check that it's the correct message // Check that it's the correct message
originalFrom := messages[1].(*mockMessage).from originalFrom := messages[1].(*mockMessage).from
if got[0].from != originalFrom { if got[0].from != originalFrom {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from) t.Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from)
} }
message := messages[0].(*mockMessage) message := messages[0].(*mockMessage)
@ -271,21 +265,17 @@ func (ms *MailerSuite) TestUnknownError() {
expectedBackoffCount := 1 expectedBackoffCount := 1
backoffCount := message.backoffCount backoffCount := message.backoffCount
if backoffCount != expectedBackoffCount { if backoffCount != expectedBackoffCount {
ms.T().Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedBackoffCount) t.Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedBackoffCount)
} }
// Check that the underlying connection was reestablished // Check that the underlying connection was reestablished
expectedDialCount := 2 expectedDialCount := 2
if dialer.dialCount != expectedDialCount { if dialer.dialCount != expectedDialCount {
ms.T().Fatalf("Did not receive expected dial count. Got %d expected %d", dialer.dialCount, expectedDialCount) t.Fatalf("Did not receive expected dial count. Got %d expected %d", dialer.dialCount, expectedDialCount)
} }
// Check that the email errored out appropriately // Check that the email errored out appropriately
if !reflect.DeepEqual(message.err, expectedError) { if !reflect.DeepEqual(message.err, expectedError) {
ms.T().Fatalf("Did not received expected error. Got %#v\nExpected %#v", message.err, expectedError) t.Fatalf("Did not received expected error. Got %#v\nExpected %#v", message.err, expectedError)
} }
} }
func TestMailerSuite(t *testing.T) {
suite.Run(t, new(MailerSuite))
}

View File

@ -13,11 +13,6 @@ import (
// being unreachable // being unreachable
var errHostUnreachable = errors.New("host unreachable") var errHostUnreachable = errors.New("host unreachable")
// errDialerUnavailable is a mock error to represent a dialer
// being unavailable (perhaps an error getting the dialer config
// or a database error)
var errDialerUnavailable = errors.New("dialer unavailable")
// mockDialer keeps track of calls to Dial // mockDialer keeps track of calls to Dial
type mockDialer struct { type mockDialer struct {
dialCount int dialCount int
@ -137,10 +132,6 @@ func (mm *mockMessage) defaultDialer() (Dialer, error) {
return newMockDialer(), nil return newMockDialer(), nil
} }
func (mm *mockMessage) errorDialer() (Dialer, error) {
return nil, errDialerUnavailable
}
func (mm *mockMessage) GetDialer() (Dialer, error) { func (mm *mockMessage) GetDialer() (Dialer, error) {
return mm.getdialer() return mm.getdialer()
} }
@ -171,6 +162,10 @@ func (mm *mockMessage) Generate(message *gomail.Message) error {
return nil return nil
} }
func (mm *mockMessage) GetSmtpFrom() (string, error) {
return mm.from, nil
}
func (mm *mockMessage) Success() error { func (mm *mockMessage) Success() error {
mm.finished = true mm.finished = true
return nil return nil

View File

@ -77,7 +77,7 @@ func RequireAPIKey(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Max-Age", "1000") w.Header().Set("Access-Control-Max-Age", "1000")
w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept") w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
return return
@ -114,13 +114,21 @@ func RequireAPIKey(handler http.Handler) http.Handler {
func RequireLogin(handler http.Handler) http.HandlerFunc { func RequireLogin(handler http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if u := ctx.Get(r, "user"); u != nil { if u := ctx.Get(r, "user"); u != nil {
// If a password change is required for the user, then redirect them
// to the login page
currentUser := u.(models.User)
if currentUser.PasswordChangeRequired && r.URL.Path != "/reset_password" {
q := r.URL.Query()
q.Set("next", r.URL.Path)
http.Redirect(w, r, fmt.Sprintf("/reset_password?%s", q.Encode()), http.StatusTemporaryRedirect)
return
}
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
return return
} }
q := r.URL.Query() q := r.URL.Query()
q.Set("next", r.URL.Path) q.Set("next", r.URL.Path)
http.Redirect(w, r, fmt.Sprintf("/login?%s", q.Encode()), http.StatusTemporaryRedirect) http.Redirect(w, r, fmt.Sprintf("/login?%s", q.Encode()), http.StatusTemporaryRedirect)
return
} }
} }
@ -168,6 +176,17 @@ func RequirePermission(perm string) func(http.Handler) http.HandlerFunc {
} }
} }
// ApplySecurityHeaders applies various security headers according to best-
// practices.
func ApplySecurityHeaders(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
csp := "frame-ancestors 'none';"
w.Header().Set("Content-Security-Policy", csp)
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
}
}
// JSONError returns an error in JSON format with the given // JSONError returns an error in JSON format with the given
// status code and message // status code and message
func JSONError(w http.ResponseWriter, c int, m string) { func JSONError(w http.ResponseWriter, c int, m string) {

View File

@ -9,19 +9,17 @@ import (
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
ctx "github.com/gophish/gophish/context" ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/stretchr/testify/suite"
) )
var successHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var successHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("success")) w.Write([]byte("success"))
}) })
type MiddlewareSuite struct { type testContext struct {
suite.Suite
apiKey string apiKey string
} }
func (s *MiddlewareSuite) SetupSuite() { func setupTest(t *testing.T) *testContext {
conf := &config.Config{ conf := &config.Config{
DBName: "sqlite3", DBName: "sqlite3",
DBPath: ":memory:", DBPath: ":memory:",
@ -29,12 +27,16 @@ func (s *MiddlewareSuite) SetupSuite() {
} }
err := models.Setup(conf) err := models.Setup(conf)
if err != nil { if err != nil {
s.T().Fatalf("Failed creating database: %v", err) t.Fatalf("Failed creating database: %v", err)
} }
// Get the API key to use for these tests // Get the API key to use for these tests
u, err := models.GetUser(1) u, err := models.GetUser(1)
s.Nil(err) if err != nil {
s.apiKey = u.ApiKey t.Fatalf("error getting user: %v", err)
}
ctx := &testContext{}
ctx.apiKey = u.ApiKey
return ctx
} }
// MiddlewarePermissionTest maps an expected HTTP Method to an expected HTTP // MiddlewarePermissionTest maps an expected HTTP Method to an expected HTTP
@ -43,7 +45,8 @@ type MiddlewarePermissionTest map[string]int
// TestEnforceViewOnly ensures that only users with the ModifyObjects // TestEnforceViewOnly ensures that only users with the ModifyObjects
// permission have the ability to send non-GET requests. // permission have the ability to send non-GET requests.
func (s *MiddlewareSuite) TestEnforceViewOnly() { func TestEnforceViewOnly(t *testing.T) {
setupTest(t)
permissionTests := map[string]MiddlewarePermissionTest{ permissionTests := map[string]MiddlewarePermissionTest{
models.RoleAdmin: MiddlewarePermissionTest{ models.RoleAdmin: MiddlewarePermissionTest{
http.MethodGet: http.StatusOK, http.MethodGet: http.StatusOK,
@ -64,7 +67,9 @@ func (s *MiddlewareSuite) TestEnforceViewOnly() {
} }
for r, checks := range permissionTests { for r, checks := range permissionTests {
role, err := models.GetRoleBySlug(r) role, err := models.GetRoleBySlug(r)
s.Nil(err) if err != nil {
t.Fatalf("error getting role by slug: %v", err)
}
for method, expected := range checks { for method, expected := range checks {
req := httptest.NewRequest(method, "/", nil) req := httptest.NewRequest(method, "/", nil)
@ -76,12 +81,16 @@ func (s *MiddlewareSuite) TestEnforceViewOnly() {
}) })
EnforceViewOnly(successHandler).ServeHTTP(response, req) EnforceViewOnly(successHandler).ServeHTTP(response, req)
s.Equal(response.Code, expected) got := response.Code
if got != expected {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
}
} }
} }
} }
func (s *MiddlewareSuite) TestRequirePermission() { func TestRequirePermission(t *testing.T) {
setupTest(t)
middleware := RequirePermission(models.PermissionModifySystem) middleware := RequirePermission(models.PermissionModifySystem)
handler := middleware(successHandler) handler := middleware(successHandler)
@ -95,26 +104,49 @@ func (s *MiddlewareSuite) TestRequirePermission() {
response := httptest.NewRecorder() response := httptest.NewRecorder()
// Test that with the requested permission, the request succeeds // Test that with the requested permission, the request succeeds
role, err := models.GetRoleBySlug(role) role, err := models.GetRoleBySlug(role)
s.Nil(err) if err != nil {
t.Fatalf("error getting role by slug: %v", err)
}
req = ctx.Set(req, "user", models.User{ req = ctx.Set(req, "user", models.User{
Role: role, Role: role,
RoleID: role.ID, RoleID: role.ID,
}) })
handler.ServeHTTP(response, req) handler.ServeHTTP(response, req)
s.Equal(response.Code, expected) got := response.Code
if got != expected {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
}
} }
} }
func (s *MiddlewareSuite) TestRequireAPIKey() { func TestRequireAPIKey(t *testing.T) {
setupTest(t)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
// Test that making a request without an API key is denied // Test that making a request without an API key is denied
RequireAPIKey(successHandler).ServeHTTP(response, req) RequireAPIKey(successHandler).ServeHTTP(response, req)
s.Equal(response.Code, http.StatusUnauthorized) expected := http.StatusUnauthorized
got := response.Code
if got != expected {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
}
} }
func (s *MiddlewareSuite) TestInvalidAPIKey() { func TestCORSHeaders(t *testing.T) {
setupTest(t)
req := httptest.NewRequest(http.MethodOptions, "/", nil)
response := httptest.NewRecorder()
RequireAPIKey(successHandler).ServeHTTP(response, req)
expected := "POST, GET, OPTIONS, PUT, DELETE"
got := response.Result().Header.Get("Access-Control-Allow-Methods")
if got != expected {
t.Fatalf("incorrect cors options received. expected %s got %s", expected, got)
}
}
func TestInvalidAPIKey(t *testing.T) {
setupTest(t)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
query := req.URL.Query() query := req.URL.Query()
query.Set("api_key", "bogus-api-key") query.Set("api_key", "bogus-api-key")
@ -122,18 +154,58 @@ func (s *MiddlewareSuite) TestInvalidAPIKey() {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
RequireAPIKey(successHandler).ServeHTTP(response, req) RequireAPIKey(successHandler).ServeHTTP(response, req)
s.Equal(response.Code, http.StatusUnauthorized) expected := http.StatusUnauthorized
got := response.Code
if got != expected {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
}
} }
func (s *MiddlewareSuite) TestBearerToken() { func TestBearerToken(t *testing.T) {
testCtx := setupTest(t)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", s.apiKey)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", testCtx.apiKey))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
RequireAPIKey(successHandler).ServeHTTP(response, req) RequireAPIKey(successHandler).ServeHTTP(response, req)
s.Equal(response.Code, http.StatusOK) expected := http.StatusOK
got := response.Code
if got != expected {
t.Fatalf("incorrect status code received. expected %d got %d", expected, got)
}
} }
func TestMiddlewareSuite(t *testing.T) { func TestPasswordResetRequired(t *testing.T) {
suite.Run(t, new(MiddlewareSuite)) req := httptest.NewRequest(http.MethodGet, "/", nil)
req = ctx.Set(req, "user", models.User{
PasswordChangeRequired: true,
})
response := httptest.NewRecorder()
RequireLogin(successHandler).ServeHTTP(response, req)
gotStatus := response.Code
expectedStatus := http.StatusTemporaryRedirect
if gotStatus != expectedStatus {
t.Fatalf("incorrect status code received. expected %d got %d", expectedStatus, gotStatus)
}
expectedLocation := "/reset_password?next=%2F"
gotLocation := response.Header().Get("Location")
if gotLocation != expectedLocation {
t.Fatalf("incorrect location header received. expected %s got %s", expectedLocation, gotLocation)
}
}
func TestApplySecurityHeaders(t *testing.T) {
expected := map[string]string{
"Content-Security-Policy": "frame-ancestors 'none';",
"X-Frame-Options": "DENY",
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
ApplySecurityHeaders(successHandler).ServeHTTP(response, req)
for header, value := range expected {
got := response.Header().Get(header)
if got != value {
t.Fatalf("incorrect security header received for %s: expected %s got %s", header, value, got)
}
}
} }

View File

@ -0,0 +1,15 @@
// Package ratelimit provides a simple token-bucket rate limiting middleware
// which only allows n POST requests every minute. This is meant to be used on
// login handlers or other sensitive transactions which should be throttled to
// prevent abuse.
//
// Tracked clients are stored in a locked map, with a goroutine that runs at a
// configurable interval to clean up stale entries.
//
// Note that there is no enforcement for GET requests. This is an effort to be
// opinionated in order to hit the most common use-cases. For more advanced
// use-cases, you may consider the `github.com/didip/tollbooth` package.
//
// The enforcement mechanism is based on the blog post here:
// https://www.alexedwards.net/blog/how-to-rate-limit-http-requests
package ratelimit

View File

@ -0,0 +1,143 @@
package ratelimit
import (
"net"
"net/http"
"sync"
"time"
log "github.com/gophish/gophish/logger"
"golang.org/x/time/rate"
)
// DefaultRequestsPerMinute is the number of requests to allow per minute.
// Any requests over this interval will return a HTTP 429 error.
const DefaultRequestsPerMinute = 5
// DefaultCleanupInterval determines how frequently the cleanup routine
// executes.
const DefaultCleanupInterval = 1 * time.Minute
// DefaultExpiry is the amount of time to track a bucket for a particular
// visitor.
const DefaultExpiry = 10 * time.Minute
type bucket struct {
limiter *rate.Limiter
lastSeen time.Time
}
// PostLimiter is a simple rate limiting middleware which only allows n POST
// requests per minute.
type PostLimiter struct {
visitors map[string]*bucket
requestLimit int
cleanupInterval time.Duration
expiry time.Duration
sync.RWMutex
}
// PostLimiterOption is a functional option that allows callers to configure
// the rate limiter.
type PostLimiterOption func(*PostLimiter)
// WithRequestsPerMinute sets the number of requests to allow per minute.
func WithRequestsPerMinute(requestLimit int) PostLimiterOption {
return func(p *PostLimiter) {
p.requestLimit = requestLimit
}
}
// WithCleanupInterval sets the interval between cleaning up stale entries in
// the rate limit client list
func WithCleanupInterval(interval time.Duration) PostLimiterOption {
return func(p *PostLimiter) {
p.cleanupInterval = interval
}
}
// WithExpiry sets the amount of time to store client entries before they are
// considered stale.
func WithExpiry(expiry time.Duration) PostLimiterOption {
return func(p *PostLimiter) {
p.expiry = expiry
}
}
// NewPostLimiter returns a new instance of a PostLimiter
func NewPostLimiter(opts ...PostLimiterOption) *PostLimiter {
limiter := &PostLimiter{
visitors: make(map[string]*bucket),
requestLimit: DefaultRequestsPerMinute,
cleanupInterval: DefaultCleanupInterval,
expiry: DefaultExpiry,
}
for _, opt := range opts {
opt(limiter)
}
go limiter.pollCleanup()
return limiter
}
func (limiter *PostLimiter) pollCleanup() {
ticker := time.NewTicker(time.Duration(limiter.cleanupInterval) * time.Second)
for range ticker.C {
limiter.Cleanup()
}
}
// Cleanup removes any buckets that were last seen past the configured expiry.
func (limiter *PostLimiter) Cleanup() {
limiter.Lock()
defer limiter.Unlock()
for ip, bucket := range limiter.visitors {
if time.Since(bucket.lastSeen) >= limiter.expiry {
delete(limiter.visitors, ip)
}
}
}
func (limiter *PostLimiter) addBucket(ip string) *bucket {
limiter.Lock()
defer limiter.Unlock()
limit := rate.NewLimiter(rate.Every(time.Minute/time.Duration(limiter.requestLimit)), limiter.requestLimit)
b := &bucket{
limiter: limit,
}
limiter.visitors[ip] = b
return b
}
func (limiter *PostLimiter) allow(ip string) bool {
// Check if we have a limiter already active for this clientIP
limiter.RLock()
bucket, exists := limiter.visitors[ip]
limiter.RUnlock()
if !exists {
bucket = limiter.addBucket(ip)
}
// Update the lastSeen for this bucket to assist with cleanup
limiter.Lock()
defer limiter.Unlock()
bucket.lastSeen = time.Now()
return bucket.limiter.Allow()
}
// Limit enforces the configured rate limit for POST requests.
//
// TODO: Change the return value to an http.Handler when we clean up the
// way Gophish routing is done.
func (limiter *PostLimiter) Limit(next http.Handler) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
clientIP = r.RemoteAddr
}
if r.Method == http.MethodPost && !limiter.allow(clientIP) {
log.Error("")
http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,59 @@
package ratelimit
import (
"net/http"
"net/http/httptest"
"testing"
)
var successHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
func reachLimit(t *testing.T, handler http.Handler, limit int) {
// Make `expected` requests and ensure that each return a successful
// response.
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.RemoteAddr = "127.0.0.1:"
for i := 0; i < limit; i++ {
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("no 200 on req %d got %d", i, w.Code)
}
}
// Then, makes another request to ensure it returns the 429
// status.
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("no 429")
}
}
func TestRateLimitEnforcement(t *testing.T) {
expectedLimit := 3
limiter := NewPostLimiter(WithRequestsPerMinute(expectedLimit))
handler := limiter.Limit(successHandler)
reachLimit(t, handler, expectedLimit)
}
func TestRateLimitCleanup(t *testing.T) {
expectedLimit := 3
limiter := NewPostLimiter(WithRequestsPerMinute(expectedLimit))
handler := limiter.Limit(successHandler)
reachLimit(t, handler, expectedLimit)
// Set the timeout to be
bucket, exists := limiter.visitors["127.0.0.1"]
if !exists {
t.Fatalf("doesn't exist for some reason")
}
bucket.lastSeen = bucket.lastSeen.Add(-limiter.expiry)
limiter.Cleanup()
_, exists = limiter.visitors["127.0.0.1"]
if exists {
t.Fatalf("exists for some reason")
}
reachLimit(t, handler, expectedLimit)
}

View File

@ -1,5 +1,17 @@
package models package models
import (
"archive/zip"
"bytes"
"encoding/base64"
"io"
"io/ioutil"
"net/url"
"path/filepath"
"regexp"
"strings"
)
// Attachment contains the fields and methods for // Attachment contains the fields and methods for
// an email attachment // an email attachment
type Attachment struct { type Attachment struct {
@ -8,4 +20,137 @@ type Attachment struct {
Content string `json:"content"` Content string `json:"content"`
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
vanillaFile bool // Vanilla file has no template variables
}
// Validate ensures that the provided attachment uses the supported template variables correctly.
func (a Attachment) Validate() error {
vc := ValidationContext{
FromAddress: "foo@bar.com",
BaseURL: "http://example.com",
}
td := Result{
BaseRecipient: BaseRecipient{
Email: "foo@bar.com",
FirstName: "Foo",
LastName: "Bar",
Position: "Test",
},
RId: "123456",
}
ptx, err := NewPhishingTemplateContext(vc, td.BaseRecipient, td.RId)
if err != nil {
return err
}
_, err = a.ApplyTemplate(ptx)
return err
}
// ApplyTemplate parses different attachment files and applies the supplied phishing template.
func (a *Attachment) ApplyTemplate(ptx PhishingTemplateContext) (io.Reader, error) {
decodedAttachment := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
// If we've already determined there are no template variables in this attachment return it immediately
if a.vanillaFile == true {
return decodedAttachment, nil
}
// Decided to use the file extension rather than the content type, as there seems to be quite
// a bit of variability with types. e.g sometimes a Word docx file would have:
// "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
fileExtension := filepath.Ext(a.Name)
switch fileExtension {
case ".docx", ".docm", ".pptx", ".xlsx", ".xlsm":
// Most modern office formats are xml based and can be unarchived.
// .docm and .xlsm files are comprised of xml, and a binary blob for the macro code
// Zip archives require random access for reading, so it's hard to stream bytes. Solution seems to be to use a buffer.
// See https://stackoverflow.com/questions/16946978/how-to-unzip-io-readcloser
b := new(bytes.Buffer)
b.ReadFrom(decodedAttachment)
zipReader, err := zip.NewReader(bytes.NewReader(b.Bytes()), int64(b.Len())) // Create a new zip reader from the file
if err != nil {
return nil, err
}
newZipArchive := new(bytes.Buffer)
zipWriter := zip.NewWriter(newZipArchive) // For writing the new archive
// i. Read each file from the Word document archive
// ii. Apply the template to it
// iii. Add the templated content to a new zip Word archive
a.vanillaFile = true
for _, zipFile := range zipReader.File {
ff, err := zipFile.Open()
if err != nil {
return nil, err
}
defer ff.Close()
contents, err := ioutil.ReadAll(ff)
if err != nil {
return nil, err
}
subFileExtension := filepath.Ext(zipFile.Name)
var tFile string
if subFileExtension == ".xml" || subFileExtension == ".rels" { // Ignore other files, e.g binary ones and images
// First we look for instances where Word has URL escaped our template variables. This seems to happen when inserting a remote image, converting {{.Foo}} to %7b%7b.foo%7d%7d.
// See https://stackoverflow.com/questions/68287630/disable-url-encoding-for-includepicture-in-microsoft-word
rx, _ := regexp.Compile("%7b%7b.([a-zA-Z]+)%7d%7d")
contents := rx.ReplaceAllFunc(contents, func(m []byte) []byte {
d, err := url.QueryUnescape(string(m))
if err != nil {
return m
}
return []byte(d)
})
// For each file apply the template.
tFile, err = ExecuteTemplate(string(contents), ptx)
if err != nil {
zipWriter.Close() // Don't use defer when writing files https://www.joeshaw.org/dont-defer-close-on-writable-files/
return nil, err
}
// Check if the subfile changed. We only need this to be set once to know in the future to check the 'parent' file
if tFile != string(contents) {
a.vanillaFile = false
}
} else {
tFile = string(contents) // Could move this to the declaration of tFile, but might be confusing to read
}
// Write new Word archive
newZipFile, err := zipWriter.Create(zipFile.Name)
if err != nil {
zipWriter.Close() // Don't use defer when writing files https://www.joeshaw.org/dont-defer-close-on-writable-files/
return nil, err
}
_, err = newZipFile.Write([]byte(tFile))
if err != nil {
zipWriter.Close()
return nil, err
}
}
zipWriter.Close()
return bytes.NewReader(newZipArchive.Bytes()), err
case ".txt", ".html", ".ics":
b, err := ioutil.ReadAll(decodedAttachment)
if err != nil {
return nil, err
}
processedAttachment, err := ExecuteTemplate(string(b), ptx)
if err != nil {
return nil, err
}
if processedAttachment == string(b) {
a.vanillaFile = true
}
return strings.NewReader(processedAttachment), nil
default:
return decodedAttachment, nil // Default is to simply return the file
}
} }

82
models/attachment_test.go Normal file
View File

@ -0,0 +1,82 @@
package models
import (
"bufio"
"encoding/base64"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"gopkg.in/check.v1"
)
func (s *ModelsSuite) TestAttachment(c *check.C) {
ptx := PhishingTemplateContext{
BaseRecipient: BaseRecipient{
FirstName: "Foo",
LastName: "Bar",
Email: "foo@bar.com",
Position: "Space Janitor",
},
BaseURL: "http://testurl.com",
URL: "http://testurl.com/?rid=1234567",
TrackingURL: "http://testurl.local/track?rid=1234567",
Tracker: "<img alt='' style='display: none' src='http://testurl.local/track?rid=1234567'/>",
From: "From Address",
RId: "1234567",
}
files, err := ioutil.ReadDir("testdata")
if err != nil {
log.Fatalf("Failed to open attachment folder 'testdata': %v\n", err)
}
for _, ff := range files {
if !ff.IsDir() && !strings.Contains(ff.Name(), "templated") {
fname := ff.Name()
fmt.Printf("Checking attachment file -> %s\n", fname)
data := readFile("testdata/" + fname)
if filepath.Ext(fname) == ".b64" {
fname = fname[:len(fname)-4]
}
a := Attachment{
Content: data,
Name: fname,
}
t, err := a.ApplyTemplate(ptx)
c.Assert(err, check.Equals, nil)
c.Assert(a.vanillaFile, check.Equals, strings.Contains(fname, "without-vars"))
c.Assert(a.vanillaFile, check.Not(check.Equals), strings.Contains(fname, "with-vars"))
// Verfify template was applied as expected
tt, err := ioutil.ReadAll(t)
if err != nil {
log.Fatalf("Failed to parse templated file '%s': %v\n", fname, err)
}
templatedFile := base64.StdEncoding.EncodeToString(tt)
expectedOutput := readFile("testdata/" + strings.TrimSuffix(ff.Name(), filepath.Ext(ff.Name())) + ".templated" + filepath.Ext(ff.Name())) // e.g text-file-with-vars.templated.txt
c.Assert(templatedFile, check.Equals, expectedOutput)
}
}
}
func readFile(fname string) string {
f, err := os.Open(fname)
if err != nil {
log.Fatalf("Failed to open file '%s': %v\n", fname, err)
}
reader := bufio.NewReader(f)
content, err := ioutil.ReadAll(reader)
if err != nil {
log.Fatalf("Failed to read file '%s': %v\n", fname, err)
}
data := ""
if filepath.Ext(fname) == ".b64" {
data = string(content)
} else {
data = base64.StdEncoding.EncodeToString(content)
}
return data
}

View File

@ -6,6 +6,7 @@ import (
"time" "time"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/webhook"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@ -26,7 +27,7 @@ type Campaign struct {
Status string `json:"status"` Status string `json:"status"`
Results []Result `json:"results,omitempty"` Results []Result `json:"results,omitempty"`
Groups []Group `json:"groups,omitempty"` Groups []Group `json:"groups,omitempty"`
Events []Event `json:"timeline,omitemtpy"` Events []Event `json:"timeline,omitempty"`
SMTPId int64 `json:"-"` SMTPId int64 `json:"-"`
SMTP SMTP `json:"smtp"` SMTP SMTP `json:"smtp"`
URL string `json:"url"` URL string `json:"url"`
@ -74,7 +75,7 @@ type CampaignStats struct {
// that occurs during the campaign // that occurs during the campaign
type Event struct { type Event struct {
Id int64 `json:"-"` Id int64 `json:"-"`
CampaignId int64 `json:"-"` CampaignId int64 `json:"campaign_id"`
Email string `json:"email"` Email string `json:"email"`
Time time.Time `json:"time"` Time time.Time `json:"time"`
Message string `json:"message"` Message string `json:"message"`
@ -154,9 +155,24 @@ func (c *Campaign) UpdateStatus(s string) error {
} }
// AddEvent creates a new campaign event in the database // AddEvent creates a new campaign event in the database
func (c *Campaign) AddEvent(e *Event) error { func AddEvent(e *Event, campaignID int64) error {
e.CampaignId = c.Id e.CampaignId = campaignID
e.Time = time.Now().UTC() e.Time = time.Now().UTC()
whs, err := GetActiveWebhooks()
if err == nil {
whEndPoints := []webhook.EndPoint{}
for _, wh := range whs {
whEndPoints = append(whEndPoints, webhook.EndPoint{
URL: wh.URL,
Secret: wh.Secret,
})
}
webhook.SendAll(whEndPoints, e)
} else {
log.Errorf("error getting active webhooks: %v", err)
}
return db.Save(e).Error return db.Save(e).Error
} }
@ -308,7 +324,7 @@ func GetCampaignSummaries(uid int64) (CampaignSummaries, error) {
cs := []CampaignSummary{} cs := []CampaignSummary{}
// Get the basic campaign information // Get the basic campaign information
query := db.Table("campaigns").Where("user_id = ?", uid) query := db.Table("campaigns").Where("user_id = ?", uid)
query = query.Select("id, name, created_date, launch_date, completed_date, status") query = query.Select("id, name, created_date, launch_date, send_by_date, completed_date, status")
err := query.Scan(&cs).Error err := query.Scan(&cs).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)
@ -331,7 +347,7 @@ func GetCampaignSummaries(uid int64) (CampaignSummaries, error) {
func GetCampaignSummary(id int64, uid int64) (CampaignSummary, error) { func GetCampaignSummary(id int64, uid int64) (CampaignSummary, error) {
cs := CampaignSummary{} cs := CampaignSummary{}
query := db.Table("campaigns").Where("user_id = ? AND id = ?", uid, id) query := db.Table("campaigns").Where("user_id = ? AND id = ?", uid, id)
query = query.Select("id, name, created_date, launch_date, completed_date, status") query = query.Select("id, name, created_date, launch_date, send_by_date, completed_date, status")
err := query.Scan(&cs).Error err := query.Scan(&cs).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)
@ -346,6 +362,38 @@ func GetCampaignSummary(id int64, uid int64) (CampaignSummary, error) {
return cs, nil return cs, nil
} }
// GetCampaignMailContext returns a campaign object with just the relevant
// data needed to generate and send emails. This includes the top-level
// metadata, the template, and the sending profile.
//
// This should only ever be used if you specifically want this lightweight
// context, since it returns a non-standard campaign object.
// ref: #1726
func GetCampaignMailContext(id int64, uid int64) (Campaign, error) {
c := Campaign{}
err := db.Where("id = ?", id).Where("user_id = ?", uid).Find(&c).Error
if err != nil {
return c, err
}
err = db.Table("smtp").Where("id=?", c.SMTPId).Find(&c.SMTP).Error
if err != nil {
return c, err
}
err = db.Where("smtp_id=?", c.SMTP.Id).Find(&c.SMTP.Headers).Error
if err != nil && err != gorm.ErrRecordNotFound {
return c, err
}
err = db.Table("templates").Where("id=?", c.TemplateId).Find(&c.Template).Error
if err != nil {
return c, err
}
err = db.Where("template_id=?", c.Template.Id).Find(&c.Template.Attachments).Error
if err != nil && err != gorm.ErrRecordNotFound {
return c, err
}
return c, nil
}
// GetCampaign returns the campaign, if it exists, specified by the given id and user_id. // GetCampaign returns the campaign, if it exists, specified by the given id and user_id.
func GetCampaign(id int64, uid int64) (Campaign, error) { func GetCampaign(id int64, uid int64) (Campaign, error) {
c := Campaign{} c := Campaign{}
@ -443,7 +491,7 @@ func PostCampaign(c *Campaign, uid int64) error {
t, err := GetTemplateByName(c.Template.Name, uid) t, err := GetTemplateByName(c.Template.Name, uid)
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"template": t.Name, "template": c.Template.Name,
}).Error("Template does not exist") }).Error("Template does not exist")
return ErrTemplateNotFound return ErrTemplateNotFound
} else if err != nil { } else if err != nil {
@ -456,7 +504,7 @@ func PostCampaign(c *Campaign, uid int64) error {
p, err := GetPageByName(c.Page.Name, uid) p, err := GetPageByName(c.Page.Name, uid)
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"page": p.Name, "page": c.Page.Name,
}).Error("Page does not exist") }).Error("Page does not exist")
return ErrPageNotFound return ErrPageNotFound
} else if err != nil { } else if err != nil {
@ -469,7 +517,7 @@ func PostCampaign(c *Campaign, uid int64) error {
s, err := GetSMTPByName(c.SMTP.Name, uid) s, err := GetSMTPByName(c.SMTP.Name, uid)
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"smtp": s.Name, "smtp": c.SMTP.Name,
}).Error("Sending profile does not exist") }).Error("Sending profile does not exist")
return ErrSMTPNotFound return ErrSMTPNotFound
} else if err != nil { } else if err != nil {
@ -484,13 +532,14 @@ func PostCampaign(c *Campaign, uid int64) error {
log.Error(err) log.Error(err)
return err return err
} }
err = c.AddEvent(&Event{Message: "Campaign Created"}) err = AddEvent(&Event{Message: "Campaign Created"}, c.Id)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
// Insert all the results // Insert all the results
resultMap := make(map[string]bool) resultMap := make(map[string]bool)
recipientIndex := 0 recipientIndex := 0
tx := db.Begin()
for _, g := range c.Groups { for _, g := range c.Groups {
// Insert a result for each target in the group // Insert a result for each target in the group
for _, t := range g.Targets { for _, t := range g.Targets {
@ -515,24 +564,30 @@ func PostCampaign(c *Campaign, uid int64) error {
Reported: false, Reported: false,
ModifiedDate: c.CreatedDate, ModifiedDate: c.CreatedDate,
} }
err = r.GenerateId() err = r.GenerateId(tx)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
continue tx.Rollback()
return err
} }
processing := false processing := false
if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) { if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) {
r.Status = StatusSending r.Status = StatusSending
processing = true processing = true
} }
err = db.Save(r).Error err = tx.Save(r).Error
if err != nil { if err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"email": t.Email, "email": t.Email,
}).Error(err) }).Errorf("error creating result: %v", err)
tx.Rollback()
return err
} }
c.Results = append(c.Results, *r) c.Results = append(c.Results, *r)
log.Infof("Creating maillog for %s to send at %s\n", r.Email, sendDate) log.WithFields(logrus.Fields{
"email": r.Email,
"send_date": sendDate,
}).Debug("creating maillog")
m := &MailLog{ m := &MailLog{
UserId: c.UserId, UserId: c.UserId,
CampaignId: c.Id, CampaignId: c.Id,
@ -540,16 +595,18 @@ func PostCampaign(c *Campaign, uid int64) error {
SendDate: sendDate, SendDate: sendDate,
Processing: processing, Processing: processing,
} }
err = db.Save(m).Error err = tx.Save(m).Error
if err != nil { if err != nil {
log.Error(err) log.WithFields(logrus.Fields{
continue "email": t.Email,
}).Errorf("error creating maillog entry: %v", err)
tx.Rollback()
return err
} }
recipientIndex++ recipientIndex++
} }
} }
err = db.Save(c).Error return tx.Commit().Error
return err
} }
//DeleteCampaign deletes the specified campaign //DeleteCampaign deletes the specified campaign
@ -604,7 +661,8 @@ func CompleteCampaign(id int64, uid int64) error {
// Mark the campaign as complete // Mark the campaign as complete
c.CompletedDate = time.Now().UTC() c.CompletedDate = time.Now().UTC()
c.Status = CampaignComplete c.Status = CampaignComplete
err = db.Where("id=? and user_id=?", id, uid).Save(&c).Error err = db.Model(&Campaign{}).Where("id=? and user_id=?", id, uid).
Select([]string{"completed_date", "status"}).UpdateColumns(&c).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }

View File

@ -1,6 +1,8 @@
package models package models
import ( import (
"fmt"
"testing"
"time" "time"
check "gopkg.in/check.v1" check "gopkg.in/check.v1"
@ -14,6 +16,11 @@ func (s *ModelsSuite) TestGenerateSendDate(c *check.C) {
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
c.Assert(campaign.LaunchDate, check.Equals, campaign.CreatedDate) c.Assert(campaign.LaunchDate, check.Equals, campaign.CreatedDate)
// For comparing the dates, we need to fetch the campaign again. This is
// to solve an issue where the campaign object right now has time down to
// the microsecond, while in MySQL it's rounded down to the second.
campaign, _ = GetCampaign(campaign.Id, campaign.UserId)
ms, err := GetMailLogsByCampaign(campaign.Id) ms, err := GetMailLogsByCampaign(campaign.Id)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
for _, m := range ms { for _, m := range ms {
@ -27,6 +34,8 @@ func (s *ModelsSuite) TestGenerateSendDate(c *check.C) {
err = PostCampaign(&campaign, campaign.UserId) err = PostCampaign(&campaign, campaign.UserId)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
campaign, _ = GetCampaign(campaign.Id, campaign.UserId)
ms, err = GetMailLogsByCampaign(campaign.Id) ms, err = GetMailLogsByCampaign(campaign.Id)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
for _, m := range ms { for _, m := range ms {
@ -41,6 +50,8 @@ func (s *ModelsSuite) TestGenerateSendDate(c *check.C) {
err = PostCampaign(&campaign, campaign.UserId) err = PostCampaign(&campaign, campaign.UserId)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
campaign, _ = GetCampaign(campaign.Id, campaign.UserId)
ms, err = GetMailLogsByCampaign(campaign.Id) ms, err = GetMailLogsByCampaign(campaign.Id)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
sendingOffset := 2 / float64(len(ms)) sendingOffset := 2 / float64(len(ms))
@ -133,3 +144,194 @@ func (s *ModelsSuite) TestCompleteCampaignAlsoDeletesMailLogs(c *check.C) {
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
c.Assert(len(ms), check.Equals, 0) c.Assert(len(ms), check.Equals, 0)
} }
func (s *ModelsSuite) TestCampaignGetResults(c *check.C) {
campaign := s.createCampaign(c)
got, err := GetCampaign(campaign.Id, campaign.UserId)
c.Assert(err, check.Equals, nil)
c.Assert(len(campaign.Results), check.Equals, len(got.Results))
}
func setupCampaignDependencies(b *testing.B, size int) {
group := Group{Name: "Test Group"}
// Create a large group of 5000 members
for i := 0; i < size; i++ {
group.Targets = append(group.Targets, Target{BaseRecipient: BaseRecipient{Email: fmt.Sprintf("test%d@example.com", i), FirstName: "User", LastName: fmt.Sprintf("%d", i)}})
}
group.UserId = 1
err := PostGroup(&group)
if err != nil {
b.Fatalf("error posting group: %v", err)
}
// Add a template
template := Template{Name: "Test Template"}
template.Subject = "{{.RId}} - Subject"
template.Text = "{{.RId}} - Text"
template.HTML = "{{.RId}} - HTML"
template.UserId = 1
err = PostTemplate(&template)
if err != nil {
b.Fatalf("error posting template: %v", err)
}
// Add a landing page
p := Page{Name: "Test Page"}
p.HTML = "<html>Test</html>"
p.UserId = 1
err = PostPage(&p)
if err != nil {
b.Fatalf("error posting page: %v", err)
}
// Add a sending profile
smtp := SMTP{Name: "Test Page"}
smtp.UserId = 1
smtp.Host = "example.com"
smtp.FromAddress = "test@test.com"
err = PostSMTP(&smtp)
if err != nil {
b.Fatalf("error posting smtp: %v", err)
}
}
// setupCampaign sets up the campaign dependencies as well as posting the
// actual campaign
func setupCampaign(b *testing.B, size int) Campaign {
setupCampaignDependencies(b, size)
campaign := Campaign{Name: "Test campaign"}
campaign.UserId = 1
campaign.Template = Template{Name: "Test Template"}
campaign.Page = Page{Name: "Test Page"}
campaign.SMTP = SMTP{Name: "Test Page"}
campaign.Groups = []Group{Group{Name: "Test Group"}}
PostCampaign(&campaign, 1)
return campaign
}
func BenchmarkCampaign100(b *testing.B) {
setupBenchmark(b)
setupCampaignDependencies(b, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
campaign := Campaign{Name: "Test campaign"}
campaign.UserId = 1
campaign.Template = Template{Name: "Test Template"}
campaign.Page = Page{Name: "Test Page"}
campaign.SMTP = SMTP{Name: "Test Page"}
campaign.Groups = []Group{Group{Name: "Test Group"}}
b.StartTimer()
err := PostCampaign(&campaign, 1)
if err != nil {
b.Fatalf("error posting campaign: %v", err)
}
b.StopTimer()
db.Delete(Result{})
db.Delete(MailLog{})
db.Delete(Campaign{})
}
tearDownBenchmark(b)
}
func BenchmarkCampaign1000(b *testing.B) {
setupBenchmark(b)
setupCampaignDependencies(b, 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
campaign := Campaign{Name: "Test campaign"}
campaign.UserId = 1
campaign.Template = Template{Name: "Test Template"}
campaign.Page = Page{Name: "Test Page"}
campaign.SMTP = SMTP{Name: "Test Page"}
campaign.Groups = []Group{Group{Name: "Test Group"}}
b.StartTimer()
err := PostCampaign(&campaign, 1)
if err != nil {
b.Fatalf("error posting campaign: %v", err)
}
b.StopTimer()
db.Delete(Result{})
db.Delete(MailLog{})
db.Delete(Campaign{})
}
tearDownBenchmark(b)
}
func BenchmarkCampaign10000(b *testing.B) {
setupBenchmark(b)
setupCampaignDependencies(b, 10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
campaign := Campaign{Name: "Test campaign"}
campaign.UserId = 1
campaign.Template = Template{Name: "Test Template"}
campaign.Page = Page{Name: "Test Page"}
campaign.SMTP = SMTP{Name: "Test Page"}
campaign.Groups = []Group{Group{Name: "Test Group"}}
b.StartTimer()
err := PostCampaign(&campaign, 1)
if err != nil {
b.Fatalf("error posting campaign: %v", err)
}
b.StopTimer()
db.Delete(Result{})
db.Delete(MailLog{})
db.Delete(Campaign{})
}
tearDownBenchmark(b)
}
func BenchmarkGetCampaign100(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := GetCampaign(campaign.Id, campaign.UserId)
if err != nil {
b.Fatalf("error getting campaign: %v", err)
}
}
tearDownBenchmark(b)
}
func BenchmarkGetCampaign1000(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := GetCampaign(campaign.Id, campaign.UserId)
if err != nil {
b.Fatalf("error getting campaign: %v", err)
}
}
tearDownBenchmark(b)
}
func BenchmarkGetCampaign5000(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 5000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := GetCampaign(campaign.Id, campaign.UserId)
if err != nil {
b.Fatalf("error getting campaign: %v", err)
}
}
tearDownBenchmark(b)
}
func BenchmarkGetCampaign10000(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := GetCampaign(campaign.Id, campaign.UserId)
if err != nil {
b.Fatalf("error getting campaign: %v", err)
}
}
tearDownBenchmark(b)
}

View File

@ -1,11 +1,8 @@
package models package models
import ( import (
"encoding/base64"
"fmt" "fmt"
"io"
"net/mail" "net/mail"
"strings"
"github.com/gophish/gomail" "github.com/gophish/gomail"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
@ -77,6 +74,10 @@ func (s *EmailRequest) Success() error {
return nil return nil
} }
func (s *EmailRequest) GetSmtpFrom() (string, error) {
return s.SMTP.FromAddress, nil
}
// PostEmailRequest stores a SendTestEmailRequest in the database. // PostEmailRequest stores a SendTestEmailRequest in the database.
func PostEmailRequest(s *EmailRequest) error { func PostEmailRequest(s *EmailRequest) error {
// Generate an ID to be used in the underlying Result object // Generate an ID to be used in the underlying Result object
@ -99,14 +100,10 @@ func GetEmailRequestByResultId(id string) (EmailRequest, error) {
// Generate fills in the details of a gomail.Message with the contents // Generate fills in the details of a gomail.Message with the contents
// from the SendTestEmailRequest. // from the SendTestEmailRequest.
func (s *EmailRequest) Generate(msg *gomail.Message) error { func (s *EmailRequest) Generate(msg *gomail.Message) error {
f, err := mail.ParseAddress(s.FromAddress) f, err := mail.ParseAddress(s.getFromAddress())
if err != nil { if err != nil {
return err return err
} }
fn := f.Name
if fn == "" {
fn = f.Address
}
msg.SetAddressHeader("From", f.Address, f.Name) msg.SetAddressHeader("From", f.Address, f.Name)
ptx, err := NewPhishingTemplateContext(s, s.BaseRecipient, s.RId) ptx, err := NewPhishingTemplateContext(s, s.BaseRecipient, s.RId)
@ -148,7 +145,7 @@ func (s *EmailRequest) Generate(msg *gomail.Message) error {
log.Error(err) log.Error(err)
} }
// don't set the Subject header if it is blank // don't set the Subject header if it is blank
if len(subject) != 0 { if subject != "" {
msg.SetHeader("Subject", subject) msg.SetHeader("Subject", subject)
} }
@ -171,16 +168,10 @@ func (s *EmailRequest) Generate(msg *gomail.Message) error {
msg.AddAlternative("text/html", html) msg.AddAlternative("text/html", html)
} }
} }
// Attach the files // Attach the files
for _, a := range s.Template.Attachments { for _, a := range s.Template.Attachments {
msg.Attach(func(a Attachment) (string, gomail.FileSetting, gomail.FileSetting) { addAttachment(msg, a, ptx)
h := map[string][]string{"Content-ID": {fmt.Sprintf("<%s>", a.Name)}}
return a.Name, gomail.SetCopyFunc(func(w io.Writer) error {
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
_, err = io.Copy(w, decoder)
return err
}), gomail.SetHeader(h)
}(a))
} }
return nil return nil

View File

@ -106,6 +106,37 @@ func (s *ModelsSuite) TestEmailRequestGenerate(ch *check.C) {
} }
} }
func (s *ModelsSuite) TestGetSmtpFrom(ch *check.C) {
smtp := SMTP{
FromAddress: "from@example.com",
}
template := Template{
Name: "Test Template",
Subject: "{{.FirstName}} - Subject",
Text: "{{.Email}} - Text",
HTML: "{{.Email}} - HTML",
}
req := &EmailRequest{
SMTP: smtp,
Template: template,
URL: "http://127.0.0.1/{{.Email}}",
BaseRecipient: BaseRecipient{
FirstName: "First",
LastName: "Last",
Email: "firstlast@example.com",
},
FromAddress: smtp.FromAddress,
RId: fmt.Sprintf("%s-foobar", PreviewPrefix),
}
msg := gomail.NewMessage()
err := req.Generate(msg)
smtp_from, err := req.GetSmtpFrom()
ch.Assert(err, check.Equals, nil)
ch.Assert(smtp_from, check.Equals, "from@example.com")
}
func (s *ModelsSuite) TestEmailRequestURLTemplating(ch *check.C) { func (s *ModelsSuite) TestEmailRequestURLTemplating(ch *check.C) {
smtp := SMTP{ smtp := SMTP{
FromAddress: "from@example.com", FromAddress: "from@example.com",

View File

@ -196,13 +196,26 @@ func PostGroup(g *Group) error {
return err return err
} }
// Insert the group into the DB // Insert the group into the DB
err := db.Save(g).Error tx := db.Begin()
err := tx.Save(g).Error
if err != nil { if err != nil {
tx.Rollback()
log.Error(err) log.Error(err)
return err return err
} }
for _, t := range g.Targets { for _, t := range g.Targets {
insertTargetIntoGroup(t, g.Id) err = insertTargetIntoGroup(tx, t, g.Id)
if err != nil {
tx.Rollback()
log.Error(err)
return err
}
}
err = tx.Commit().Error
if err != nil {
log.Error(err)
tx.Rollback()
return err
} }
return nil return nil
} }
@ -213,7 +226,6 @@ func PutGroup(g *Group) error {
return err return err
} }
// Fetch group's existing targets from database. // Fetch group's existing targets from database.
ts := []Target{}
ts, err := GetTargets(g.Id) ts, err := GetTargets(g.Id)
if err != nil { if err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
@ -221,48 +233,63 @@ func PutGroup(g *Group) error {
}).Error("Error getting targets from group") }).Error("Error getting targets from group")
return err return err
} }
// Check existing targets, removing any that are no longer in the group. // Preload the caches
tExists := false cacheNew := make(map[string]int64, len(g.Targets))
for _, t := range g.Targets {
cacheNew[t.Email] = t.Id
}
cacheExisting := make(map[string]int64, len(ts))
for _, t := range ts { for _, t := range ts {
tExists = false cacheExisting[t.Email] = t.Id
// Is the target still in the group?
for _, nt := range g.Targets {
if t.Email == nt.Email {
tExists = true
break
} }
tx := db.Begin()
// Check existing targets, removing any that are no longer in the group.
for _, t := range ts {
if _, ok := cacheNew[t.Email]; ok {
continue
} }
// If the target does not exist in the group any longer, we delete it // If the target does not exist in the group any longer, we delete it
if !tExists { err := tx.Where("group_id=? and target_id=?", g.Id, t.Id).Delete(&GroupTarget{}).Error
err := db.Where("group_id=? and target_id=?", g.Id, t.Id).Delete(&GroupTarget{}).Error
if err != nil { if err != nil {
tx.Rollback()
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"email": t.Email, "email": t.Email,
}).Error("Error deleting email") }).Error("Error deleting email")
} }
} }
}
// Add any targets that are not in the database yet. // Add any targets that are not in the database yet.
for _, nt := range g.Targets { for _, nt := range g.Targets {
// Check and see if the target already exists in the db // If the target already exists in the database, we should just update
tExists = false // the record with the latest information.
for _, t := range ts { if id, ok := cacheExisting[nt.Email]; ok {
if t.Email == nt.Email { nt.Id = id
tExists = true err = UpdateTarget(tx, nt)
nt.Id = t.Id
break
}
}
// Add target if not in database, otherwise update target information.
if !tExists {
insertTargetIntoGroup(nt, g.Id)
} else {
UpdateTarget(nt)
}
}
err = db.Save(g).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)
tx.Rollback()
return err
}
continue
}
// Otherwise, add target if not in database
err = insertTargetIntoGroup(tx, nt, g.Id)
if err != nil {
log.Error(err)
tx.Rollback()
return err
}
}
err = tx.Save(g).Error
if err != nil {
log.Error(err)
return err
}
err = tx.Commit().Error
if err != nil {
tx.Rollback()
return err return err
} }
return nil return nil
@ -285,55 +312,42 @@ func DeleteGroup(g *Group) error {
return err return err
} }
func insertTargetIntoGroup(t Target, gid int64) error { func insertTargetIntoGroup(tx *gorm.DB, t Target, gid int64) error {
if _, err := mail.ParseAddress(t.Email); err != nil { if _, err := mail.ParseAddress(t.Email); err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"email": t.Email, "email": t.Email,
}).Error("Invalid email") }).Error("Invalid email")
return err return err
} }
trans := db.Begin() err := tx.Where(t).FirstOrCreate(&t).Error
err := trans.Where(t).FirstOrCreate(&t).Error
if err != nil { if err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"email": t.Email, "email": t.Email,
}).Error(err) }).Error(err)
trans.Rollback()
return err return err
} }
err = trans.Where("group_id=? and target_id=?", gid, t.Id).Find(&GroupTarget{}).Error err = tx.Save(&GroupTarget{GroupId: gid, TargetId: t.Id}).Error
if err == gorm.ErrRecordNotFound {
err = trans.Save(&GroupTarget{GroupId: gid, TargetId: t.Id}).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)
trans.Rollback()
return err return err
} }
}
if err != nil { if err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"email": t.Email, "email": t.Email,
}).Error("Error adding many-many mapping") }).Error("Error adding many-many mapping")
trans.Rollback()
return err
}
err = trans.Commit().Error
if err != nil {
trans.Rollback()
log.Error("Error committing db changes")
return err return err
} }
return nil return nil
} }
// UpdateTarget updates the given target information in the database. // UpdateTarget updates the given target information in the database.
func UpdateTarget(target Target) error { func UpdateTarget(tx *gorm.DB, target Target) error {
targetInfo := map[string]interface{}{ targetInfo := map[string]interface{}{
"first_name": target.FirstName, "first_name": target.FirstName,
"last_name": target.LastName, "last_name": target.LastName,
"position": target.Position, "position": target.Position,
} }
err := db.Model(&target).Where("id = ?", target.Id).Updates(targetInfo).Error err := tx.Model(&target).Where("id = ?", target.Id).Updates(targetInfo).Error
if err != nil { if err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"email": target.Email, "email": target.Email,

View File

@ -1,6 +1,9 @@
package models package models
import ( import (
"fmt"
"testing"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"gopkg.in/check.v1" "gopkg.in/check.v1"
) )
@ -170,3 +173,121 @@ func (s *ModelsSuite) TestPutGroupEmptyAttribute(c *check.C) {
c.Assert(targets[1].FirstName, check.Equals, "Second") c.Assert(targets[1].FirstName, check.Equals, "Second")
c.Assert(targets[1].LastName, check.Equals, "Example") c.Assert(targets[1].LastName, check.Equals, "Example")
} }
func benchmarkPostGroup(b *testing.B, iter, size int) {
b.StopTimer()
g := &Group{
Name: fmt.Sprintf("Group-%d", iter),
}
for i := 0; i < size; i++ {
g.Targets = append(g.Targets, Target{
BaseRecipient: BaseRecipient{
FirstName: "User",
LastName: fmt.Sprintf("%d", i),
Email: fmt.Sprintf("test-%d@test.com", i),
},
})
}
b.StartTimer()
err := PostGroup(g)
if err != nil {
b.Fatalf("error posting group: %v", err)
}
}
// benchmarkPutGroup modifies half of the group to simulate a large change
func benchmarkPutGroup(b *testing.B, iter, size int) {
b.StopTimer()
// First, we need to create the group
g := &Group{
Name: fmt.Sprintf("Group-%d", iter),
}
for i := 0; i < size; i++ {
g.Targets = append(g.Targets, Target{
BaseRecipient: BaseRecipient{
FirstName: "User",
LastName: fmt.Sprintf("%d", i),
Email: fmt.Sprintf("test-%d@test.com", i),
},
})
}
err := PostGroup(g)
if err != nil {
b.Fatalf("error posting group: %v", err)
}
// Now we need to change half of the group.
for i := 0; i < size/2; i++ {
g.Targets[i].Email = fmt.Sprintf("test-modified-%d@test.com", i)
}
b.StartTimer()
err = PutGroup(g)
if err != nil {
b.Fatalf("error modifying group: %v", err)
}
}
func BenchmarkPostGroup100(b *testing.B) {
setupBenchmark(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchmarkPostGroup(b, i, 100)
b.StopTimer()
resetBenchmark(b)
}
tearDownBenchmark(b)
}
func BenchmarkPostGroup1000(b *testing.B) {
setupBenchmark(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchmarkPostGroup(b, i, 1000)
b.StopTimer()
resetBenchmark(b)
}
tearDownBenchmark(b)
}
func BenchmarkPostGroup10000(b *testing.B) {
setupBenchmark(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchmarkPostGroup(b, i, 10000)
b.StopTimer()
resetBenchmark(b)
}
tearDownBenchmark(b)
}
func BenchmarkPutGroup100(b *testing.B) {
setupBenchmark(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchmarkPutGroup(b, i, 100)
b.StopTimer()
resetBenchmark(b)
}
tearDownBenchmark(b)
}
func BenchmarkPutGroup1000(b *testing.B) {
setupBenchmark(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchmarkPutGroup(b, i, 1000)
b.StopTimer()
resetBenchmark(b)
}
tearDownBenchmark(b)
}
func BenchmarkPutGroup10000(b *testing.B) {
setupBenchmark(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
benchmarkPutGroup(b, i, 10000)
b.StopTimer()
resetBenchmark(b)
}
tearDownBenchmark(b)
}

154
models/imap.go Normal file
View File

@ -0,0 +1,154 @@
package models
import (
"errors"
"net"
"time"
log "github.com/gophish/gophish/logger"
)
const DefaultIMAPFolder = "INBOX"
const DefaultIMAPFreq = 60 // Every 60 seconds
// IMAP contains the attributes needed to handle logging into an IMAP server to check
// for reported emails
type IMAP struct {
UserId int64 `json:"-" gorm:"column:user_id"`
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port uint16 `json:"port,string,omitempty"`
Username string `json:"username"`
Password string `json:"password"`
TLS bool `json:"tls"`
IgnoreCertErrors bool `json:"ignore_cert_errors"`
Folder string `json:"folder"`
RestrictDomain string `json:"restrict_domain"`
DeleteReportedCampaignEmail bool `json:"delete_reported_campaign_email"`
LastLogin time.Time `json:"last_login,omitempty"`
ModifiedDate time.Time `json:"modified_date"`
IMAPFreq uint32 `json:"imap_freq,string,omitempty"`
}
// ErrIMAPHostNotSpecified is thrown when there is no Host specified
// in the IMAP configuration
var ErrIMAPHostNotSpecified = errors.New("No IMAP Host specified")
// ErrIMAPPortNotSpecified is thrown when there is no Port specified
// in the IMAP configuration
var ErrIMAPPortNotSpecified = errors.New("No IMAP Port specified")
// ErrInvalidIMAPHost indicates that the IMAP server string is invalid
var ErrInvalidIMAPHost = errors.New("Invalid IMAP server address")
// ErrInvalidIMAPPort indicates that the IMAP Port is invalid
var ErrInvalidIMAPPort = errors.New("Invalid IMAP Port")
// ErrIMAPUsernameNotSpecified is thrown when there is no Username specified
// in the IMAP configuration
var ErrIMAPUsernameNotSpecified = errors.New("No Username specified")
// ErrIMAPPasswordNotSpecified is thrown when there is no Password specified
// in the IMAP configuration
var ErrIMAPPasswordNotSpecified = errors.New("No Password specified")
// ErrInvalidIMAPFreq is thrown when the frequency for polling the
// IMAP server is invalid
var ErrInvalidIMAPFreq = errors.New("Invalid polling frequency")
// TableName specifies the database tablename for Gorm to use
func (im IMAP) TableName() string {
return "imap"
}
// Validate ensures that IMAP configs/connections are valid
func (im *IMAP) Validate() error {
switch {
case im.Host == "":
return ErrIMAPHostNotSpecified
case im.Port == 0:
return ErrIMAPPortNotSpecified
case im.Username == "":
return ErrIMAPUsernameNotSpecified
case im.Password == "":
return ErrIMAPPasswordNotSpecified
}
// Set the default value for Folder
if im.Folder == "" {
im.Folder = DefaultIMAPFolder
}
// Make sure im.Host is an IP or hostname. NB will fail if unable to resolve the hostname.
ip := net.ParseIP(im.Host)
_, err := net.LookupHost(im.Host)
if ip == nil && err != nil {
return ErrInvalidIMAPHost
}
// Make sure 1 >= port <= 65535
if im.Port < 1 || im.Port > 65535 {
return ErrInvalidIMAPPort
}
// Make sure the polling frequency is between every 30 seconds and every year
// If not set it to the default
if im.IMAPFreq < 30 || im.IMAPFreq > 31540000 {
im.IMAPFreq = DefaultIMAPFreq
}
return nil
}
// GetIMAP returns the IMAP server owned by the given user.
func GetIMAP(uid int64) ([]IMAP, error) {
im := []IMAP{}
count := 0
err := db.Where("user_id=?", uid).Find(&im).Count(&count).Error
if err != nil {
log.Error(err)
return im, err
}
return im, nil
}
// PostIMAP updates IMAP settings for a user in the database.
func PostIMAP(im *IMAP, uid int64) error {
err := im.Validate()
if err != nil {
log.Error(err)
return err
}
// Delete old entry. TODO: Save settings and if fails to Save below replace with original
err = DeleteIMAP(uid)
if err != nil {
log.Error(err)
return err
}
// Insert new settings into the DB
err = db.Save(im).Error
if err != nil {
log.Error("Unable to save to database: ", err.Error())
}
return err
}
// DeleteIMAP deletes the existing IMAP in the database.
func DeleteIMAP(uid int64) error {
err := db.Where("user_id=?", uid).Delete(&IMAP{}).Error
if err != nil {
log.Error(err)
}
return err
}
func SuccessfulLogin(im *IMAP) error {
err := db.Model(&im).Where("user_id = ?", im.UserId).Update("last_login", time.Now().UTC()).Error
if err != nil {
log.Error("Unable to update database: ", err.Error())
}
return err
}

View File

@ -2,7 +2,6 @@ package models
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -10,6 +9,7 @@ import (
"math/big" "math/big"
"net/mail" "net/mail"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -27,6 +27,9 @@ var MaxSendAttempts = 8
// MailLog is exceeded. // MailLog is exceeded.
var ErrMaxSendAttempts = errors.New("max send attempts exceeded") var ErrMaxSendAttempts = errors.New("max send attempts exceeded")
// Attachments with these file extensions have inline disposition
var embeddedFileExtensions = []string{".jpg", ".jpeg", ".png", ".gif"}
// MailLog is a struct that holds information about an email that is to be // MailLog is a struct that holds information about an email that is to be
// sent out. // sent out.
type MailLog struct { type MailLog struct {
@ -37,6 +40,8 @@ type MailLog struct {
SendDate time.Time `json:"send_date"` SendDate time.Time `json:"send_date"`
SendAttempt int `json:"send_attempt"` SendAttempt int `json:"send_attempt"`
Processing bool `json:"-"` Processing bool `json:"-"`
cachedCampaign *Campaign
} }
// GenerateMailLog creates a new maillog for the given campaign and // GenerateMailLog creates a new maillog for the given campaign and
@ -123,18 +128,42 @@ func (m *MailLog) Success() error {
return err return err
} }
err = db.Delete(m).Error err = db.Delete(m).Error
return nil return err
} }
// GetDialer returns a dialer based on the maillog campaign's SMTP configuration // GetDialer returns a dialer based on the maillog campaign's SMTP configuration
func (m *MailLog) GetDialer() (mailer.Dialer, error) { func (m *MailLog) GetDialer() (mailer.Dialer, error) {
c, err := GetCampaign(m.CampaignId, m.UserId) c := m.cachedCampaign
if c == nil {
campaign, err := GetCampaignMailContext(m.CampaignId, m.UserId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
c = &campaign
}
return c.SMTP.GetDialer() return c.SMTP.GetDialer()
} }
// CacheCampaign allows bulk-mail workers to cache the otherwise expensive
// campaign lookup operation by providing a pointer to the campaign here.
func (m *MailLog) CacheCampaign(campaign *Campaign) error {
if campaign.Id != m.CampaignId {
return fmt.Errorf("incorrect campaign provided for caching. expected %d got %d", m.CampaignId, campaign.Id)
}
m.cachedCampaign = campaign
return nil
}
func (m *MailLog) GetSmtpFrom() (string, error) {
c, err := GetCampaign(m.CampaignId, m.UserId)
if err != nil {
return "", err
}
f, err := mail.ParseAddress(c.SMTP.FromAddress)
return f.Address, err
}
// Generate fills in the details of a gomail.Message instance with // Generate fills in the details of a gomail.Message instance with
// the correct headers and body from the campaign and recipient listed in // the correct headers and body from the campaign and recipient listed in
// the maillog. We accept the gomail.Message as an argument so that the caller // the maillog. We accept the gomail.Message as an argument so that the caller
@ -144,18 +173,25 @@ func (m *MailLog) Generate(msg *gomail.Message) error {
if err != nil { if err != nil {
return err return err
} }
c, err := GetCampaign(m.CampaignId, m.UserId) c := m.cachedCampaign
if c == nil {
campaign, err := GetCampaignMailContext(m.CampaignId, m.UserId)
if err != nil { if err != nil {
return err return err
} }
c = &campaign
}
f, err := mail.ParseAddress(c.SMTP.FromAddress) f, err := mail.ParseAddress(c.Template.EnvelopeSender)
if err != nil {
f, err = mail.ParseAddress(c.SMTP.FromAddress)
if err != nil { if err != nil {
return err return err
} }
}
msg.SetAddressHeader("From", f.Address, f.Name) msg.SetAddressHeader("From", f.Address, f.Name)
ptx, err := NewPhishingTemplateContext(&c, r.BaseRecipient, r.RId) ptx, err := NewPhishingTemplateContext(c, r.BaseRecipient, r.RId)
if err != nil { if err != nil {
return err return err
} }
@ -191,11 +227,12 @@ func (m *MailLog) Generate(msg *gomail.Message) error {
// Parse remaining templates // Parse remaining templates
subject, err := ExecuteTemplate(c.Template.Subject, ptx) subject, err := ExecuteTemplate(c.Template.Subject, ptx)
if err != nil { if err != nil {
log.Warn(err) log.Warn(err)
} }
// don't set Subject header if the subject is empty // don't set Subject header if the subject is empty
if len(subject) != 0 { if subject != "" {
msg.SetHeader("Subject", subject) msg.SetHeader("Subject", subject)
} }
@ -220,14 +257,7 @@ func (m *MailLog) Generate(msg *gomail.Message) error {
} }
// Attach the files // Attach the files
for _, a := range c.Template.Attachments { for _, a := range c.Template.Attachments {
msg.Attach(func(a Attachment) (string, gomail.FileSetting, gomail.FileSetting) { addAttachment(msg, a, ptx)
h := map[string][]string{"Content-ID": {fmt.Sprintf("<%s>", a.Name)}}
return a.Name, gomail.SetCopyFunc(func(w io.Writer) error {
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
_, err = io.Copy(w, decoder)
return err
}), gomail.SetHeader(h)
}(a))
} }
return nil return nil
@ -299,3 +329,35 @@ func (m *MailLog) generateMessageID() (string, error) {
msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, h) msgid := fmt.Sprintf("<%d.%d.%d@%s>", t, pid, rint, h)
return msgid, nil return msgid, nil
} }
// Check if an attachment should have inline disposition based on
// its file extension.
func shouldEmbedAttachment(name string) bool {
ext := filepath.Ext(name)
for _, v := range embeddedFileExtensions {
if strings.EqualFold(ext, v) {
return true
}
}
return false
}
// Add an attachment to a gomail message, with the Content-Disposition
// header set to inline or attachment depending on its file extension.
func addAttachment(msg *gomail.Message, a Attachment, ptx PhishingTemplateContext) {
copyFunc := gomail.SetCopyFunc(func(c Attachment) func(w io.Writer) error {
return func(w io.Writer) error {
reader, err := a.ApplyTemplate(ptx)
if err != nil {
return err
}
_, err = io.Copy(w, reader)
return err
}
}(a))
if shouldEmbedAttachment(a.Name) {
msg.Embed(a.Name, copyFunc)
} else {
msg.Attach(a.Name, copyFunc)
}
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"math" "math"
"net/textproto" "net/textproto"
"testing"
"time" "time"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
@ -212,15 +213,51 @@ func (s *ModelsSuite) TestGenerateMailLog(ch *check.C) {
ch.Assert(m.Processing, check.Equals, false) ch.Assert(m.Processing, check.Equals, false)
} }
func (s *ModelsSuite) TestMailLogGetSmtpFrom(ch *check.C) {
template := Template{
Name: "OverrideSmtpFrom",
UserId: 1,
Text: "dummytext",
HTML: "Dummyhtml",
Subject: "Dummysubject",
EnvelopeSender: "spoofing@example.com",
}
ch.Assert(PostTemplate(&template), check.Equals, nil)
campaign := s.createCampaignDependencies(ch)
campaign.Template = template
ch.Assert(PostCampaign(&campaign, campaign.UserId), check.Equals, nil)
result := campaign.Results[0]
m := &MailLog{}
err := db.Where("r_id=? AND campaign_id=?", result.RId, campaign.Id).
Find(m).Error
ch.Assert(err, check.Equals, nil)
msg := gomail.NewMessage()
err = m.Generate(msg)
ch.Assert(err, check.Equals, nil)
msgBuff := &bytes.Buffer{}
_, err = msg.WriteTo(msgBuff)
ch.Assert(err, check.Equals, nil)
got, err := email.NewEmailFromReader(msgBuff)
ch.Assert(err, check.Equals, nil)
ch.Assert(got.From, check.Equals, "spoofing@example.com")
}
func (s *ModelsSuite) TestMailLogGenerate(ch *check.C) { func (s *ModelsSuite) TestMailLogGenerate(ch *check.C) {
campaign := s.createCampaign(ch) campaign := s.createCampaign(ch)
result := campaign.Results[0] result := campaign.Results[0]
expected := &email.Email{ expected := &email.Email{
From: "test@test.com", // Default smtp.FromAddress
Subject: fmt.Sprintf("%s - Subject", result.RId), Subject: fmt.Sprintf("%s - Subject", result.RId),
Text: []byte(fmt.Sprintf("%s - Text", result.RId)), Text: []byte(fmt.Sprintf("%s - Text", result.RId)),
HTML: []byte(fmt.Sprintf("%s - HTML", result.RId)), HTML: []byte(fmt.Sprintf("%s - HTML", result.RId)),
} }
got := s.emailFromFirstMailLog(campaign, ch) got := s.emailFromFirstMailLog(campaign, ch)
ch.Assert(got.From, check.Equals, expected.From)
ch.Assert(got.Subject, check.Equals, expected.Subject) ch.Assert(got.Subject, check.Equals, expected.Subject)
ch.Assert(string(got.Text), check.Equals, string(expected.Text)) ch.Assert(string(got.Text), check.Equals, string(expected.Text))
ch.Assert(string(got.HTML), check.Equals, string(expected.HTML)) ch.Assert(string(got.HTML), check.Equals, string(expected.HTML))
@ -247,7 +284,7 @@ func (s *ModelsSuite) TestMailLogGenerateOverrideTransparencyHeaders(ch *check.C
smtp := SMTP{ smtp := SMTP{
Name: "Test SMTP", Name: "Test SMTP",
Host: "1.1.1.1:25", Host: "1.1.1.1:25",
FromAddress: "Foo Bar <foo@example.com>", FromAddress: "foo@example.com",
UserId: 1, UserId: 1,
Headers: []Header{ Headers: []Header{
Header{Key: "X-Gophish-Contact", Value: ""}, Header{Key: "X-Gophish-Contact", Value: ""},
@ -322,3 +359,111 @@ func (s *ModelsSuite) TestMailLogGenerateEmptySubject(ch *check.C) {
got := s.emailFromFirstMailLog(campaign, ch) got := s.emailFromFirstMailLog(campaign, ch)
ch.Assert(got.Subject, check.Equals, expected.Subject) ch.Assert(got.Subject, check.Equals, expected.Subject)
} }
func (s *ModelsSuite) TestShouldEmbedAttachment(ch *check.C) {
// Supported file extensions
ch.Assert(shouldEmbedAttachment(".png"), check.Equals, true)
ch.Assert(shouldEmbedAttachment(".jpg"), check.Equals, true)
ch.Assert(shouldEmbedAttachment(".jpeg"), check.Equals, true)
ch.Assert(shouldEmbedAttachment(".gif"), check.Equals, true)
// Some other file extensions
ch.Assert(shouldEmbedAttachment(".docx"), check.Equals, false)
ch.Assert(shouldEmbedAttachment(".txt"), check.Equals, false)
ch.Assert(shouldEmbedAttachment(".jar"), check.Equals, false)
ch.Assert(shouldEmbedAttachment(".exe"), check.Equals, false)
// Invalid input
ch.Assert(shouldEmbedAttachment(""), check.Equals, false)
ch.Assert(shouldEmbedAttachment("png"), check.Equals, false)
}
func (s *ModelsSuite) TestEmbedAttachment(ch *check.C) {
campaign := s.createCampaignDependencies(ch)
campaign.Template.Attachments = []Attachment{
{
Name: "test.png",
Type: "image/png",
Content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=",
},
{
Name: "test.txt",
Type: "text/plain",
Content: "VGVzdCB0ZXh0IGZpbGU=",
},
}
PutTemplate(&campaign.Template)
ch.Assert(PostCampaign(&campaign, campaign.UserId), check.Equals, nil)
got := s.emailFromFirstMailLog(campaign, ch)
// The email package simply ignores attachments where the Content-Disposition header is set
// to inline, so the best we can do without replacing the whole thing is to check that only
// the text file was added as an attachment.
ch.Assert(got.Attachments, check.HasLen, 1)
ch.Assert(got.Attachments[0].Filename, check.Equals, "test.txt")
}
func BenchmarkMailLogGenerate100(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 100)
ms, err := GetMailLogsByCampaign(campaign.Id)
if err != nil {
b.Fatalf("error getting maillogs for campaign: %v", err)
}
ms[0].CacheCampaign(&campaign)
b.ResetTimer()
for i := 0; i < b.N; i++ {
msg := gomail.NewMessage()
ms[0].Generate(msg)
}
tearDownBenchmark(b)
}
func BenchmarkMailLogGenerate1000(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 1000)
ms, err := GetMailLogsByCampaign(campaign.Id)
if err != nil {
b.Fatalf("error getting maillogs for campaign: %v", err)
}
ms[0].CacheCampaign(&campaign)
b.ResetTimer()
for i := 0; i < b.N; i++ {
msg := gomail.NewMessage()
ms[0].Generate(msg)
}
tearDownBenchmark(b)
}
func BenchmarkMailLogGenerate5000(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 5000)
ms, err := GetMailLogsByCampaign(campaign.Id)
if err != nil {
b.Fatalf("error getting maillogs for campaign: %v", err)
}
ms[0].CacheCampaign(&campaign)
b.ResetTimer()
for i := 0; i < b.N; i++ {
msg := gomail.NewMessage()
ms[0].Generate(msg)
}
tearDownBenchmark(b)
}
func BenchmarkMailLogGenerate10000(b *testing.B) {
setupBenchmark(b)
campaign := setupCampaign(b, 10000)
ms, err := GetMailLogsByCampaign(campaign.Id)
if err != nil {
b.Fatalf("error getting maillogs for campaign: %v", err)
}
ms[0].CacheCampaign(&campaign)
b.ResetTimer()
for i := 0; i < b.N; i++ {
msg := gomail.NewMessage()
ms[0].Generate(msg)
}
tearDownBenchmark(b)
}

View File

@ -2,14 +2,20 @@ package models
import ( import (
"crypto/rand" "crypto/rand"
"crypto/tls"
"crypto/x509"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"os"
"time" "time"
"bitbucket.org/liamstask/goose/lib/goose" "bitbucket.org/liamstask/goose/lib/goose"
_ "github.com/go-sql-driver/mysql" // Blank import needed to import mysql mysql "github.com/go-sql-driver/mysql"
"github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3 _ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
@ -20,6 +26,19 @@ var conf *config.Config
const MaxDatabaseConnectionAttempts int = 10 const MaxDatabaseConnectionAttempts int = 10
// DefaultAdminUsername is the default username for the administrative user
const DefaultAdminUsername = "admin"
// InitialAdminPassword is the environment variable that specifies which
// password to use for the initial root login instead of generating one
// randomly
const InitialAdminPassword = "GOPHISH_INITIAL_ADMIN_PASSWORD"
// InitialAdminApiToken is the environment variable that specifies the
// API token to seed the initial root login instead of generating one
// randomly
const InitialAdminApiToken = "GOPHISH_INITIAL_ADMIN_API_TOKEN"
const ( const (
CampaignInProgress string = "In progress" CampaignInProgress string = "In progress"
CampaignQueued string = "Queued" CampaignQueued string = "Queued"
@ -79,8 +98,38 @@ func chooseDBDriver(name, openStr string) goose.DBDriver {
return d return d
} }
// Setup initializes the Conn object func createTemporaryPassword(u *User) error {
// It also populates the Gophish Config object var temporaryPassword string
if envPassword := os.Getenv(InitialAdminPassword); envPassword != "" {
temporaryPassword = envPassword
} else {
// This will result in a 16 character password which could be viewed as an
// inconvenience, but it should be ok for now.
temporaryPassword = auth.GenerateSecureKey(auth.MinPasswordLength)
}
hash, err := auth.GeneratePasswordHash(temporaryPassword)
if err != nil {
return err
}
u.Hash = hash
// Anytime a temporary password is created, we will force the user
// to change their password
u.PasswordChangeRequired = true
err = db.Save(u).Error
if err != nil {
return err
}
log.Infof("Please login with the username admin and the password %s", temporaryPassword)
return nil
}
// Setup initializes the database and runs any needed migrations.
//
// First, it establishes a connection to the database, then runs any migrations
// newer than the version the database is on.
//
// Once the database is up-to-date, we create an admin user (if needed) that
// has a randomly generated API key and password.
func Setup(c *config.Config) error { func Setup(c *config.Config) error {
// Setup the package-scoped config // Setup the package-scoped config
conf = c conf = c
@ -96,6 +145,30 @@ func Setup(c *config.Config) error {
log.Error(err) log.Error(err)
return err return err
} }
// Register certificates for tls encrypted db connections
if conf.DBSSLCaPath != "" {
switch conf.DBName {
case "mysql":
rootCertPool := x509.NewCertPool()
pem, err := ioutil.ReadFile(conf.DBSSLCaPath)
if err != nil {
log.Error(err)
return err
}
if ok := rootCertPool.AppendCertsFromPEM(pem); !ok {
log.Error("Failed to append PEM.")
return err
}
mysql.RegisterTLSConfig("ssl_ca", &tls.Config{
RootCAs: rootCertPool,
})
// Default database is sqlite3, which supports no tls, as connection
// is file based
default:
}
}
// Open our database connection // Open our database connection
i := 0 i := 0
for { for {
@ -126,6 +199,7 @@ func Setup(c *config.Config) error {
} }
// Create the admin user if it doesn't exist // Create the admin user if it doesn't exist
var userCount int64 var userCount int64
var adminUser User
db.Model(&User{}).Count(&userCount) db.Model(&User{}).Count(&userCount)
adminRole, err := GetRoleBySlug(RoleAdmin) adminRole, err := GetRoleBySlug(RoleAdmin)
if err != nil { if err != nil {
@ -133,14 +207,44 @@ func Setup(c *config.Config) error {
return err return err
} }
if userCount == 0 { if userCount == 0 {
initUser := User{ adminUser := User{
Username: "admin", Username: DefaultAdminUsername,
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish
Role: adminRole, Role: adminRole,
RoleID: adminRole.ID, RoleID: adminRole.ID,
PasswordChangeRequired: true,
} }
initUser.ApiKey = generateSecureKey()
err = db.Save(&initUser).Error if envToken := os.Getenv(InitialAdminApiToken); envToken != "" {
adminUser.ApiKey = envToken
} else {
adminUser.ApiKey = auth.GenerateSecureKey(auth.APIKeyLength)
}
err = db.Save(&adminUser).Error
if err != nil {
log.Error(err)
return err
}
}
// If this is the first time the user is installing Gophish, then we will
// generate a temporary password for the admin user.
//
// We do this here instead of in the block above where the admin is created
// since there's the chance the user executes Gophish and has some kind of
// error, then tries restarting it. If they didn't grab the password out of
// the logs, then they would have lost it.
//
// By doing the temporary password here, we will regenerate that temporary
// password until the user is able to reset the admin password.
if adminUser.Username == "" {
adminUser, err = GetUserByUsername(DefaultAdminUsername)
if err != nil {
log.Error(err)
return err
}
}
if adminUser.PasswordChangeRequired {
err = createTemporaryPassword(&adminUser)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
return err return err

View File

@ -96,5 +96,44 @@ func (s *ModelsSuite) createCampaign(ch *check.C) Campaign {
c := s.createCampaignDependencies(ch) c := s.createCampaignDependencies(ch)
// Setup and "launch" our campaign // Setup and "launch" our campaign
ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil) ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil)
// For comparing the dates, we need to fetch the campaign again. This is
// to solve an issue where the campaign object right now has time down to
// the microsecond, while in MySQL it's rounded down to the second.
c, _ = GetCampaign(c.Id, c.UserId)
return c return c
} }
func setupBenchmark(b *testing.B) {
conf := &config.Config{
DBName: "sqlite3",
DBPath: ":memory:",
MigrationsPath: "../db/db_sqlite3/migrations/",
}
err := Setup(conf)
if err != nil {
b.Fatalf("Failed creating database: %v", err)
}
}
func tearDownBenchmark(b *testing.B) {
err := db.Close()
if err != nil {
b.Fatalf("error closing database: %v", err)
}
}
func resetBenchmark(b *testing.B) {
db.Delete(Group{})
db.Delete(Target{})
db.Delete(GroupTarget{})
db.Delete(SMTP{})
db.Delete(Page{})
db.Delete(Result{})
db.Delete(MailLog{})
db.Delete(Campaign{})
// Reset users table to default state.
db.Not("id", 1).Delete(User{})
db.Model(User{}).Update("username", "admin")
}

View File

@ -138,6 +138,9 @@ func PostPage(p *Page) error {
// Per the PUT Method RFC, it presumes all data for a page is provided. // Per the PUT Method RFC, it presumes all data for a page is provided.
func PutPage(p *Page) error { func PutPage(p *Page) error {
err := p.Validate() err := p.Validate()
if err != nil {
return err
}
err = db.Where("id=?", p.Id).Save(p).Error err = db.Where("id=?", p.Id).Save(p).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)

View File

@ -39,10 +39,6 @@ type Result struct {
} }
func (r *Result) createEvent(status string, details interface{}) (*Event, error) { func (r *Result) createEvent(status string, details interface{}) (*Event, error) {
c, err := GetCampaign(r.CampaignId, r.UserId)
if err != nil {
return nil, err
}
e := &Event{Email: r.Email, Message: status} e := &Event{Email: r.Email, Message: status}
if details != nil { if details != nil {
dj, err := json.Marshal(details) dj, err := json.Marshal(details)
@ -51,7 +47,7 @@ func (r *Result) createEvent(status string, details interface{}) (*Event, error)
} }
e.Details = string(dj) e.Details = string(dj)
} }
c.AddEvent(e) AddEvent(e, r.CampaignId)
return e, nil return e, nil
} }
@ -189,7 +185,7 @@ func generateResultId() (string, error) {
// GenerateId generates a unique key to represent the result // GenerateId generates a unique key to represent the result
// in the database // in the database
func (r *Result) GenerateId() error { func (r *Result) GenerateId(tx *gorm.DB) error {
// Keep trying until we generate a unique key (shouldn't take more than one or two iterations) // Keep trying until we generate a unique key (shouldn't take more than one or two iterations)
for { for {
rid, err := generateResultId() rid, err := generateResultId()
@ -197,7 +193,7 @@ func (r *Result) GenerateId() error {
return err return err
} }
r.RId = rid r.RId = rid
err = db.Table("results").Where("r_id=?", r.RId).First(&Result{}).Error err = tx.Table("results").Where("r_id=?", r.RId).First(&Result{}).Error
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
break break
} }

View File

@ -10,7 +10,7 @@ import (
func (s *ModelsSuite) TestGenerateResultId(c *check.C) { func (s *ModelsSuite) TestGenerateResultId(c *check.C) {
r := Result{} r := Result{}
r.GenerateId() r.GenerateId(db)
match, err := regexp.Match("[a-zA-Z0-9]{7}", []byte(r.RId)) match, err := regexp.Match("[a-zA-Z0-9]{7}", []byte(r.RId))
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
c.Assert(match, check.Equals, true) c.Assert(match, check.Equals, true)

View File

@ -5,11 +5,13 @@ import (
"errors" "errors"
"net/mail" "net/mail"
"os" "os"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gophish/gomail" "github.com/gophish/gomail"
"github.com/gophish/gophish/dialer"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/mailer" "github.com/gophish/gophish/mailer"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
@ -56,6 +58,10 @@ type Header struct {
// specified in the SMTP configuration // specified in the SMTP configuration
var ErrFromAddressNotSpecified = errors.New("No From Address specified") var ErrFromAddressNotSpecified = errors.New("No From Address specified")
// ErrInvalidFromAddress is thrown when the SMTP From field in the sending
// profiles containes a value that is not an email address
var ErrInvalidFromAddress = errors.New("Invalid SMTP From address because it is not an email address")
// ErrHostNotSpecified is thrown when there is no Host specified // ErrHostNotSpecified is thrown when there is no Host specified
// in the SMTP configuration // in the SMTP configuration
var ErrHostNotSpecified = errors.New("No SMTP Host specified") var ErrHostNotSpecified = errors.New("No SMTP Host specified")
@ -75,6 +81,8 @@ func (s *SMTP) Validate() error {
return ErrFromAddressNotSpecified return ErrFromAddressNotSpecified
case s.Host == "": case s.Host == "":
return ErrHostNotSpecified return ErrHostNotSpecified
case !validateFromAddress(s.FromAddress):
return ErrInvalidFromAddress
} }
_, err := mail.ParseAddress(s.FromAddress) _, err := mail.ParseAddress(s.FromAddress)
if err != nil { if err != nil {
@ -94,6 +102,12 @@ func (s *SMTP) Validate() error {
return err return err
} }
// validateFromAddress validates
func validateFromAddress(email string) bool {
r, _ := regexp.Compile("^([a-zA-Z0-9_\\-\\.]+)@([a-zA-Z0-9_\\-\\.]+)\\.([a-zA-Z]{2,18})$")
return r.MatchString(email)
}
// GetDialer returns a dialer for the given SMTP profile // GetDialer returns a dialer for the given SMTP profile
func (s *SMTP) GetDialer() (mailer.Dialer, error) { func (s *SMTP) GetDialer() (mailer.Dialer, error) {
// Setup the message and dial // Setup the message and dial
@ -101,6 +115,7 @@ func (s *SMTP) GetDialer() (mailer.Dialer, error) {
if len(hp) < 2 { if len(hp) < 2 {
hp = append(hp, "25") hp = append(hp, "25")
} }
host := hp[0]
// Any issues should have been caught in validation, but we'll // Any issues should have been caught in validation, but we'll
// double check here. // double check here.
port, err := strconv.Atoi(hp[1]) port, err := strconv.Atoi(hp[1])
@ -108,9 +123,10 @@ func (s *SMTP) GetDialer() (mailer.Dialer, error) {
log.Error(err) log.Error(err)
return nil, err return nil, err
} }
d := gomail.NewDialer(hp[0], port, s.Username, s.Password) dialer := dialer.Dialer()
d := gomail.NewWithDialer(dialer, host, port, s.Username, s.Password)
d.TLSConfig = &tls.Config{ d.TLSConfig = &tls.Config{
ServerName: s.Host, ServerName: host,
InsecureSkipVerify: s.IgnoreCertErrors, InsecureSkipVerify: s.IgnoreCertErrors,
} }
hostname, err := os.Hostname() hostname, err := os.Hostname()

View File

@ -12,7 +12,7 @@ func (s *ModelsSuite) TestPostSMTP(c *check.C) {
smtp := SMTP{ smtp := SMTP{
Name: "Test SMTP", Name: "Test SMTP",
Host: "1.1.1.1:25", Host: "1.1.1.1:25",
FromAddress: "Foo Bar <foo@example.com>", FromAddress: "foo@example.com",
UserId: 1, UserId: 1,
} }
err := PostSMTP(&smtp) err := PostSMTP(&smtp)
@ -25,7 +25,7 @@ func (s *ModelsSuite) TestPostSMTP(c *check.C) {
func (s *ModelsSuite) TestPostSMTPNoHost(c *check.C) { func (s *ModelsSuite) TestPostSMTPNoHost(c *check.C) {
smtp := SMTP{ smtp := SMTP{
Name: "Test SMTP", Name: "Test SMTP",
FromAddress: "Foo Bar <foo@example.com>", FromAddress: "foo@example.com",
UserId: 1, UserId: 1,
} }
err := PostSMTP(&smtp) err := PostSMTP(&smtp)
@ -42,12 +42,34 @@ func (s *ModelsSuite) TestPostSMTPNoFrom(c *check.C) {
c.Assert(err, check.Equals, ErrFromAddressNotSpecified) c.Assert(err, check.Equals, ErrFromAddressNotSpecified)
} }
func (s *ModelsSuite) TestPostSMTPValidHeader(c *check.C) { func (s *ModelsSuite) TestPostInvalidFrom(c *check.C) {
smtp := SMTP{ smtp := SMTP{
Name: "Test SMTP", Name: "Test SMTP",
Host: "1.1.1.1:25", Host: "1.1.1.1:25",
FromAddress: "Foo Bar <foo@example.com>", FromAddress: "Foo Bar <foo@example.com>",
UserId: 1, UserId: 1,
}
err := PostSMTP(&smtp)
c.Assert(err, check.Equals, ErrInvalidFromAddress)
}
func (s *ModelsSuite) TestPostInvalidFromEmail(c *check.C) {
smtp := SMTP{
Name: "Test SMTP",
Host: "1.1.1.1:25",
FromAddress: "example.com",
UserId: 1,
}
err := PostSMTP(&smtp)
c.Assert(err, check.Equals, ErrInvalidFromAddress)
}
func (s *ModelsSuite) TestPostSMTPValidHeader(c *check.C) {
smtp := SMTP{
Name: "Test SMTP",
Host: "1.1.1.1:25",
FromAddress: "foo@example.com",
UserId: 1,
Headers: []Header{ Headers: []Header{
Header{Key: "Reply-To", Value: "test@example.com"}, Header{Key: "Reply-To", Value: "test@example.com"},
Header{Key: "X-Mailer", Value: "gophish"}, Header{Key: "X-Mailer", Value: "gophish"},
@ -73,7 +95,7 @@ func (s *ModelsSuite) TestSMTPGetDialer(ch *check.C) {
dialer := d.(*Dialer).Dialer dialer := d.(*Dialer).Dialer
ch.Assert(dialer.Host, check.Equals, host) ch.Assert(dialer.Host, check.Equals, host)
ch.Assert(dialer.Port, check.Equals, port) ch.Assert(dialer.Port, check.Equals, port)
ch.Assert(dialer.TLSConfig.ServerName, check.Equals, smtp.Host) ch.Assert(dialer.TLSConfig.ServerName, check.Equals, host)
ch.Assert(dialer.TLSConfig.InsecureSkipVerify, check.Equals, smtp.IgnoreCertErrors) ch.Assert(dialer.TLSConfig.InsecureSkipVerify, check.Equals, smtp.IgnoreCertErrors)
} }
@ -81,3 +103,15 @@ func (s *ModelsSuite) TestGetInvalidSMTP(ch *check.C) {
_, err := GetSMTP(-1, 1) _, err := GetSMTP(-1, 1)
ch.Assert(err, check.Equals, gorm.ErrRecordNotFound) ch.Assert(err, check.Equals, gorm.ErrRecordNotFound)
} }
func (s *ModelsSuite) TestDefaultDeniedDial(ch *check.C) {
host := "169.254.169.254"
port := 25
smtp := SMTP{
Host: fmt.Sprintf("%s:%d", host, port),
}
d, err := smtp.GetDialer()
ch.Assert(err, check.Equals, nil)
_, err = d.Dial()
ch.Assert(err, check.ErrorMatches, ".*upstream connection denied.*")
}

View File

@ -2,6 +2,7 @@ package models
import ( import (
"errors" "errors"
"net/mail"
"time" "time"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
@ -13,6 +14,7 @@ type Template struct {
Id int64 `json:"id" gorm:"column:id; primary_key:yes"` Id int64 `json:"id" gorm:"column:id; primary_key:yes"`
UserId int64 `json:"-" gorm:"column:user_id"` UserId int64 `json:"-" gorm:"column:user_id"`
Name string `json:"name"` Name string `json:"name"`
EnvelopeSender string `json:"envelope_sender"`
Subject string `json:"subject"` Subject string `json:"subject"`
Text string `json:"text"` Text string `json:"text"`
HTML string `json:"html" gorm:"column:html"` HTML string `json:"html" gorm:"column:html"`
@ -33,6 +35,11 @@ func (t *Template) Validate() error {
return ErrTemplateNameNotSpecified return ErrTemplateNameNotSpecified
case t.Text == "" && t.HTML == "": case t.Text == "" && t.HTML == "":
return ErrTemplateMissingParameter return ErrTemplateMissingParameter
case t.EnvelopeSender != "":
_, err := mail.ParseAddress(t.EnvelopeSender)
if err != nil {
return err
}
} }
if err := ValidateTemplate(t.HTML); err != nil { if err := ValidateTemplate(t.HTML); err != nil {
return err return err
@ -40,6 +47,12 @@ func (t *Template) Validate() error {
if err := ValidateTemplate(t.Text); err != nil { if err := ValidateTemplate(t.Text); err != nil {
return err return err
} }
for _, a := range t.Attachments {
if err := a.Validate(); err != nil {
return err
}
}
return nil return nil
} }

View File

@ -47,7 +47,10 @@ func NewPhishingTemplateContext(ctx TemplateContext, r BaseRecipient, rid string
// For the base URL, we'll reset the the path and the query // For the base URL, we'll reset the the path and the query
// This will create a URL in the form of http://example.com // This will create a URL in the form of http://example.com
baseURL, _ := url.Parse(templateURL) baseURL, err := url.Parse(templateURL)
if err != nil {
return PhishingTemplateContext{}, err
}
baseURL.Path = "" baseURL.Path = ""
baseURL.RawQuery = "" baseURL.RawQuery = ""

View File

@ -0,0 +1,51 @@
BEGIN:VCALENDAR
PRODID:-//zoom.us//iCalendar Event//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
CLASS:PUBLIC
BEGIN:VTIMEZONE
TZID:Europe/London
TZURL:http://tzurl.org/zoneinfo-outlook/Europe/London
X-LIC-LOCATION:Europe/London
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19700329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19701025T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20210306T182251Z
DTSTART;TZID=Europe/London:20210306T183000
DTEND;TZID=Europe/London:20210306T190000
SUMMARY:Gophish Test Calendar
UID:20210306T182251Z-89336450000@fe80:0:0:0:31:49ff:fec9:f252ens5
TZID:Europe/London
DESCRIPTION:Glenn Wilkinson is inviting you to a scheduled Zoom meeting.\
n\nJoin Zoom Meeting\n{{.URL}}\n\nMeeting ID: 893 3645 9466\nPasscode: 31337\
nOne tap mobile\n+442039017895\,\,89336450000#\,\,\,\,*509879# United Ki
ngdom\n+441314601196\,\,89336450000#\,\,\,\,*509879# United Kingdom\n\nD
ial by your location\n +44 203 901 7895 United Kingdom\n +
44 131 460 1196 United Kingdom\n +44 203 051 2874 United Kingdom\
n +44 203 481 5237 United Kingdom\n +44 203 481 5240 Unite
d Kingdom\n +1 253 215 8782 US (Tacoma)\n +1 301 715 8592
US (Washington DC)\n +1 312 626 6799 US (Chicago)\n +1 346
248 7799 US (Houston)\n +1 646 558 8656 US (New York)\n +
1 669 900 9128 US https://us02web.zoom.us/u/kpXDbMrN\n\n
LOCATION:{{.URL}}
BEGIN:VALARM
TRIGGER:-PT10M
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,51 @@
BEGIN:VCALENDAR
PRODID:-//zoom.us//iCalendar Event//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
CLASS:PUBLIC
BEGIN:VTIMEZONE
TZID:Europe/London
TZURL:http://tzurl.org/zoneinfo-outlook/Europe/London
X-LIC-LOCATION:Europe/London
BEGIN:DAYLIGHT
TZOFFSETFROM:+0000
TZOFFSETTO:+0100
TZNAME:BST
DTSTART:19700329T010000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0100
TZOFFSETTO:+0000
TZNAME:GMT
DTSTART:19701025T020000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20210306T182251Z
DTSTART;TZID=Europe/London:20210306T183000
DTEND;TZID=Europe/London:20210306T190000
SUMMARY:Gophish Test Calendar
UID:20210306T182251Z-89336450000@fe80:0:0:0:31:49ff:fec9:f252ens5
TZID:Europe/London
DESCRIPTION:Glenn Wilkinson is inviting you to a scheduled Zoom meeting.\
n\nJoin Zoom Meeting\nhttp://testurl.com/?rid=1234567\n\nMeeting ID: 893 3645 9466\nPasscode: 31337\
nOne tap mobile\n+442039017895\,\,89336450000#\,\,\,\,*509879# United Ki
ngdom\n+441314601196\,\,89336450000#\,\,\,\,*509879# United Kingdom\n\nD
ial by your location\n +44 203 901 7895 United Kingdom\n +
44 131 460 1196 United Kingdom\n +44 203 051 2874 United Kingdom\
n +44 203 481 5237 United Kingdom\n +44 203 481 5240 Unite
d Kingdom\n +1 253 215 8782 US (Tacoma)\n +1 301 715 8592
US (Washington DC)\n +1 312 626 6799 US (Chicago)\n +1 346
248 7799 US (Houston)\n +1 646 558 8656 US (New York)\n +
1 669 900 9128 US https://us02web.zoom.us/u/kpXDbMrN\n\n
LOCATION:http://testurl.com/?rid=1234567
BEGIN:VALARM
TRIGGER:-PT10M
ACTION:DISPLAY
DESCRIPTION:Reminder
END:VALARM
END:VEVENT
END:VCALENDAR

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
<html>
<head><title>Page for {{.FirstName}}</title></head>
<body>
Hello {{.FirstName}} {{.LastName}} <p>
Click <a href="{{.URL}}">here</a> for a legit link.
</body>
{{.Tracker}}
</html>

View File

@ -0,0 +1,13 @@
<html>
<head><title>Page for Foo</title></head>
<body>
Hello Foo Bar <p>
Click <a href="http://testurl.com/?rid=1234567">here</a> for a legit link.
</body>
<img alt='' style='display: none' src='http://testurl.local/track?rid=1234567'/>
</html>

View File

@ -0,0 +1,9 @@
<html>
<head><title>There are no variables here.</title></head>
<body>
There are no vars in this file.
</body>
</html>

View File

@ -0,0 +1,9 @@
<html>
<head><title>There are no variables here.</title></head>
<body>
There are no vars in this file.
</body>
</html>

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More