Skip to content

Commit

Permalink
CSS cloned site token (#297)
Browse files Browse the repository at this point in the history
* Initial stab at a new token that uses CSS to detect a cloned website

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix naming scheme for CSS cloned site token

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Added the expected referrer field for css cloned site

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Tying in the JS preprocessing to the request

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix referer grabbing

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Polishing the CSS token

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Show the referrer for a CSS clone site

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Mark default CSS as important to override other styles

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Force HTTP by default to prevent it being blocked

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Move to a CF function-based CSS cloned site

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Actually call the canarytoken.value function :S

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Show the passed referer

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Added CSS download functionality to the CSS cloned site token

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fixing download button

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Initial progress on Azure app integration

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Actually add the new logic to the repo

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Adding reqs to poetry list

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Update poetry lock

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Updating around a pyOpenSSL error

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix typo in generate button and css_landing_handling

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix typo in GraphClient creation

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Minor UX improvements

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Change secret to str to allow for use in azure.identity objects

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix redirect function call

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix redirect function call again

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix redirect function call again v2

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Addressing Jay's comments about None checking

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Separate AWS-side code from the AWS token to avoid confusion

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Improved error handling and information conveyed to user

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fixing a JS issue, adding a link to MS docs, and remove some old testing comments

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Remove code from status

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Improve docs and form-control CSS

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Restore missing CSS from merge

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Add newline to install button

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Change away from the msgraph client that's depreciated

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix type in auth header

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Add logging for Azure Graph API failures

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Add styling to azure css page

* Add logic to create an OrganizationalBranding object if one doesn't exist

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Set content-type

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Add steps for azure token

* Sort out remaining html

* Update deps

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Added flow to check for existing CSS and concatinate them when safe

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix typo in URL

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Remove empty code block from status page

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix JSON encoding

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Make automatic vs manual more clear

* Clean up a duplicate variable declaration

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Added reference material for Graph API calls

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix manual step link

* Fix pre-commit issues

* Fix more precommit issue

* Fixes to UI

* First stab at separately the CSS and Entra token

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Added CSS callback

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fixes for split token UI

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Adding the referrer data to email notice

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Update FastAPI to latest same minor

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix URL test failure

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix some class names

* Fix frontend tests

* Defang suspected phishing URLs from email alerts

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Fix unit test

* Bump Coverage and remove coverage from Windows tests

* Fix slow redirect test

* Styling tweaks

Replace entra png

* Update the CF Function to log missing Referer headers, and further defanging of the phishing URLs

Signed-off-by: Jacob Torrey <jacob@thinkst.com>

* Make precommit happy

* rm comment

---------

Signed-off-by: Jacob Torrey <jacob@thinkst.com>
Co-authored-by: Jason Bissict <jason@thinkst.com>
Co-authored-by: Max Mclaughlin <max@thinkst.com>
  • Loading branch information
3 people authored Jan 30, 2024
1 parent 5b4c882 commit b6512fa
Show file tree
Hide file tree
Showing 25 changed files with 2,642 additions and 1,311 deletions.
3 changes: 2 additions & 1 deletion .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ services:
# Used to test wireguard token locally.
- NET_ADMIN
- SYS_MODULE

links:
- redis
redis:
image: redis
container_name: "redis-store"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,4 @@ jobs:
copy .\frontend\frontend.env.dist .\frontend\frontend.env
copy .\switchboard\switchboard.env.dist .\switchboard\switchboard.env
cd tests
poetry run coverage run --source=.\integration -m pytest .\integration\test_custom_binary.py --runv3
poetry run pytest .\integration\test_custom_binary.py --runv3 -v
42 changes: 42 additions & 0 deletions aws-css-token-infra/CSSClonedSiteCFFunc/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Cloudfront Function to check the referrer compared to the expected one base64 encoded into the URI
// Expected uri looks like: /TOKEN_ID/escape(btoa(expected_referrer))/imagename.gif
// Either returns a 1x1 pixel GIF, or forwards it to the token server with the referrer as a GET parameter for reporting

var token_server = 'https://canarytokens.com';

function handler(event) {
var uri = event.request.uri.split('/');
var expected_referrer = '';
expected_referrer = String.bytesFrom(uri[2], 'base64url');
var referer = '';
if ('referer' in event.request.headers)
referer = event.request.headers.referer.value;

if (expected_referrer == '')
console.log("Empty expected_referrer!");
if (referer == '')
console.log("Empty/missing Referer header for: " + expected_referrer);

if (expected_referrer == '' || referer == '' || referer.indexOf(expected_referrer) >= 0) { // Happy case where the referer matches
var response = {
statusCode: 200,
statusDescription: 'OK',
headers: {
'content-type': { value: 'image/gif' }
},
body: "\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff"
+ "\xff\xff\xff\x21\xf9\x04\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00"
+ "\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b"
};
return response;
}
// Default case of redirecting to the tokens server
var response = {
statusCode: 302,
statusDescription: 'Found',
headers: {
'location': { value: token_server + '/' + uri[1] + '/' + uri[3] + '?r=' + referer }
}
};
return response;
}
158 changes: 158 additions & 0 deletions canarytokens/azure_css.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
from azure.identity import ClientSecretCredential
from canarytokens.settings import FrontendSettings
from requests import Response, get, put, delete, post
from time import sleep
from cssutils.css import CSSStyleRule
import logging
import cssutils

frontend_settings = FrontendSettings()

BearerToken = str


def _auth_to_tenant(tenant_id: str) -> BearerToken:
"""
Tenant that the client app has permissions to, returns a Graph API Bearer token
"""
cred = ClientSecretCredential(
tenant_id, frontend_settings.AZUREAPP_ID, frontend_settings.AZUREAPP_SECRET
)
return cred.get_token(".default").token


def _check_if_custom_branding(token: BearerToken, tenant_id: str) -> bool:
"""
Checks to see if a tenant has custom branding (and if it's not safe to install our custom CSS)
Reference: https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get
Returns: True if there is branding present, false otherwise
"""
headers = {"Accept-Language": "0", "Authorization": "Bearer " + token}
res: Response = get(
f"https://graph.microsoft.com/v1.0/organization/{tenant_id}/branding",
headers=headers,
)
# This API returns 404 if there is no corporate branding configured
return res.status_code != 404


def _check_existing_body_background(css: str) -> bool:
"""
Parses an existing CSS file to see if there is a conflicting rule
Returns True if there is a conflicting rule, False otherwise
"""
css_rules: list[CSSStyleRule] = cssutils.parseString(css)
for rule in css_rules:
if rule.selectorText == "body" and "background" in rule.style.cssText:
return True
return False


def _check_if_can_install_custom_css(
token: BearerToken, tenant_id: str
) -> tuple[bool, str]:
"""
Checks to see if a tenant has custom branding (and if it's not safe to install our custom CSS)
References:
- https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get
- https://learn.microsoft.com/en-us/graph/api/resources/organizationalbrandingproperties
Returns: A tuple of (True, css) if we can safely install a custom CSS, False otherwise
"""
headers = {"Accept-Language": "0", "Authorization": "Bearer " + token}
res: Response = get(
f"https://graph.microsoft.com/v1.0/organization/{tenant_id}/branding",
headers=headers,
)
if res.status_code == 404: # There is no branding at all
return (True, "")
if res.status_code != 200: # Other error
return (False, "")
if (
res.json().get("customCSSRelativeUrl") is None
): # Is there another CSS? If not then we can install!
return (True, "")
# There is an existing CSS, let's check for compatiblity
res: Response = get(
f"https://graph.microsoft.com/v1.0/organization/{tenant_id}/branding/localizations/0/customCSS",
headers=headers,
)
if res.status_code == 200:
return (not _check_existing_body_background(res.text), res.text)
return (False, "")


def _install_custom_css(token: BearerToken, tenant_id: str, css: str) -> bool:
"""
Attempts to configure the tenant with the custom css
References:
- https://learn.microsoft.com/en-us/graph/api/organizationalbranding-update
- https://learn.microsoft.com/en-us/graph/api/organizationalbranding-post-localizations
Returns: True if successful, False otherwise
"""
headers = {"Authorization": "Bearer " + token, "Accept-Language": "0"}
if not _check_if_custom_branding(token, tenant_id):
# If we need to create a default OrganizationalBranding object, we set a blank string to a single space to create it
logging.error("Creating a new default organizationalBranding object...")
res: Response = post(
f"https://graph.microsoft.com/v1.0/organization/{tenant_id}/branding/localizations/",
json={"usernameHintText": " "},
headers=headers,
)
if res.status_code != 201:
logging.error(
f"Unable to create OrganizationalBranding object: {res.status_code} - {res.text}"
)
return False
sleep(5) # Give the Graph API a second to recognize it's built

headers["Content-Type"] = "text/css"
res: Response = put(
f"https://graph.microsoft.com/v1.0/organization/{tenant_id}/branding/localizations/0/customCSS",
data=css.encode(),
headers=headers,
)
if res.status_code != 204:
logging.error(f"Unable to add customCSS: {res.status_code} - {res.text}")
return res.status_code == 204


def _delete_self(token: BearerToken) -> bool:
"""
Tries to delete itself from the tenant to reduce risk
Returns: True is successful, False otherwise
"""
res: Response = delete(
f"https://graph.microsoft.com/v1.0/servicePrincipals(appId='{frontend_settings.AZUREAPP_ID}')",
headers={"Authorization": "Bearer " + token},
)
return res.status_code == 204


def install_azure_css(tenant_id: str, css: str) -> tuple[bool, str]:
"""
Main business logic function to install the Azure CSS token into the tenant
NB: Must be called after the Azure permission consent workflow has occurred
Returns: True on success, False otherwise
"""
token = _auth_to_tenant(tenant_id)
(check, existing_css) = _check_if_can_install_custom_css(token, tenant_id)
if not check:
return (
False,
"Installation failed: your tenant already has a conflicting custom CSS, please manually add the CSS to your portal branding.",
)
if not _install_custom_css(token, tenant_id, existing_css + css):
# Might as well remove ourselves anyways
_delete_self(token)
return (
False,
"Installation failed: Unable to automatically install the CSS, please manually add the CSS to your portal branding.",
)
_delete_self(token)
return (
True,
"Successfully installed the CSS into your Azure tenant. Please wait for a few minutes for the changes to propogate; no further action is needed.",
)


# Other reference that's of note but not linked to from the other Graph API docs: https://learn.microsoft.com/en-us/graph/api/organizationalbrandinglocalization-delete
17 changes: 16 additions & 1 deletion canarytokens/canarydrop.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from datetime import datetime, timedelta
from hashlib import md5
from pathlib import Path
from urllib.parse import quote
from typing import Any, Literal, Optional, Union

from pydantic import BaseModel, Field, parse_obj_as, root_validator
Expand Down Expand Up @@ -104,9 +105,11 @@ class Canarydrop(BaseModel):
sql_server_view_name: Optional[str]
sql_server_function_name: Optional[str]
sql_server_trigger_name: Optional[str]
# Custom upload stuff.
# Custom upload stuff
file_contents: Optional[str]
file_name: Optional[str]
# CSS cloned site stuff
expected_referrer: Optional[str]

# AWS key specific stuff
aws_access_key_id: Optional[str]
Expand Down Expand Up @@ -324,6 +327,18 @@ def get_cloned_site_javascript(self, force_https: bool = False):
)
return clonedsite_js

def get_cloned_site_css(self, cf_url: str):
token_val = self.canarytoken.value()
expected_referrer = quote(b64encode(self.expected_referrer.encode()).decode())
clonedsite_css = textwrap.dedent(
f"""
body {{
background: url('{cf_url}/{token_val}/{expected_referrer}/img.gif') !important;
}}
"""
)
return clonedsite_css

@staticmethod
def generate_mysql_usage(
token: str, domain: str, port: int, encoded: bool = True
Expand Down
Loading

0 comments on commit b6512fa

Please sign in to comment.