diff --git a/canarytokens/canarydrop.py b/canarytokens/canarydrop.py index af190e530..ee0273db2 100644 --- a/canarytokens/canarydrop.py +++ b/canarytokens/canarydrop.py @@ -18,6 +18,7 @@ from pathlib import Path from urllib.parse import quote from typing import Any, Literal, Optional, Union +from canarytokens.webdav import FsType from pydantic import AnyHttpUrl, BaseModel, Field, parse_obj_as, root_validator @@ -114,6 +115,10 @@ class Canarydrop(BaseModel): file_name: Optional[str] # CSS cloned site stuff expected_referrer: Optional[str] + # WebDAV specific stuff + webdav_fs_type: Optional[FsType] + webdav_password: Optional[str] + webdav_server: Optional[str] # AWS key specific stuff aws_access_key_id: Optional[str] diff --git a/canarytokens/channel_http.py b/canarytokens/channel_http.py index 8c39cd219..007343f5f 100644 --- a/canarytokens/channel_http.py +++ b/canarytokens/channel_http.py @@ -203,6 +203,11 @@ def render_POST(self, request: Request): # noqa: C901 canarydrop.add_canarydrop_hit(token_hit=token_hit) self.dispatch(canarydrop=canarydrop, token_hit=token_hit) return b"success" + elif canarydrop.type == TokenTypes.WEBDAV: + token_hit = Canarytoken._get_info_for_webdav(request) + canarydrop.add_canarydrop_hit(token_hit=token_hit) + self.dispatch(canarydrop=canarydrop, token_hit=token_hit) + return b"success" elif canarydrop.type in [ TokenTypes.SLOW_REDIRECT, TokenTypes.WEB_IMAGE, diff --git a/canarytokens/models.py b/canarytokens/models.py index 993267852..2eb3842dd 100644 --- a/canarytokens/models.py +++ b/canarytokens/models.py @@ -283,6 +283,7 @@ class TokenTypes(str, enum.Enum): ADOBE_PDF = "adobe_pdf" WIREGUARD = "wireguard" WINDOWS_DIR = "windows_dir" + WEBDAV = "webdav" CLONEDSITE = "clonedsite" CSSCLONEDSITE = "cssclonedsite" CREDIT_CARD_V2 = "credit_card_v2" @@ -327,6 +328,7 @@ def __str__(self) -> str: TokenTypes.ADOBE_PDF: "Adobe PDF", TokenTypes.WIREGUARD: "WireGuard", TokenTypes.WINDOWS_DIR: "Windows folder", + TokenTypes.WEBDAV: "Network folder", TokenTypes.CLONEDSITE: "JS cloned website", TokenTypes.CSSCLONEDSITE: "CSS cloned website", TokenTypes.QR_CODE: "QR code", @@ -777,6 +779,21 @@ class WindowsDirectoryTokenRequest(TokenRequest): token_type: Literal[TokenTypes.WINDOWS_DIR] = TokenTypes.WINDOWS_DIR +class WebDavTokenRequest(TokenRequest): + token_type: Literal[TokenTypes.WEBDAV] = TokenTypes.WEBDAV + webdav_fs_type: str + + @validator("webdav_fs_type") + def check_webdav_fs_type(value: str): + from canarytokens.webdav import FsType + + if not value.upper() in FsType.__members__.keys(): + raise ValueError( + f"webdav_fs_type must be in the FsType enum. Given: {value}" + ) + return value + + class CreditCardV2TokenRequest(TokenRequest): token_type: Literal[TokenTypes.CREDIT_CARD_V2] = TokenTypes.CREDIT_CARD_V2 cf_turnstile_response: Optional[str] @@ -798,6 +815,7 @@ class CreditCardV2TokenRequest(TokenRequest): ClonedWebTokenRequest, CSSClonedWebTokenRequest, WindowsDirectoryTokenRequest, + WebDavTokenRequest, WebBugTokenRequest, SlowRedirectTokenRequest, MySQLTokenRequest, @@ -1061,6 +1079,13 @@ class WindowsDirectoryTokenResponse(TokenResponse): token_type: Literal[TokenTypes.WINDOWS_DIR] = TokenTypes.WINDOWS_DIR +class WebDavTokenResponse(TokenResponse): + webdav_fs_type: str + token_type: Literal[TokenTypes.WEBDAV] = TokenTypes.WEBDAV + webdav_password: str + webdav_server: str + + class SMTPTokenResponse(TokenResponse): token_type: Literal[TokenTypes.SMTP] = TokenTypes.SMTP unique_email: Optional[EmailStr] @@ -1123,6 +1148,7 @@ class CreditCardV2TokenResponse(TokenResponse): MySQLTokenResponse, WireguardTokenResponse, WindowsDirectoryTokenResponse, + WebDavTokenResponse, FastRedirectTokenResponse, ClonedWebTokenResponse, CSSClonedWebTokenResponse, @@ -1339,6 +1365,7 @@ class AdditionalInfo(BaseModel): mysql_client: Optional[dict[str, list[str]]] r: Optional[list[str]] l: Optional[list[str]] + file_path: Optional[list[str]] def serialize_for_v2(self) -> dict: data = json_safe_dict(self) @@ -1657,6 +1684,19 @@ class WindowsDirectoryTokenHit(TokenHit): src_data: Optional[dict] +class WebDavAdditionalInfo(BaseModel): + file_path: Optional[str] + useragent: Optional[str] + + def serialize_for_v2(self) -> dict: + return self.dict() + + +class WebDavTokenHit(TokenHit): + token_type: Literal[TokenTypes.WEBDAV] = TokenTypes.WEBDAV + additional_info: Optional[WebDavAdditionalInfo] + + class MsExcelDocumentTokenHit(TokenHit): token_type: Literal[TokenTypes.MS_EXCEL] = TokenTypes.MS_EXCEL @@ -1792,6 +1832,7 @@ class LegacyTokenHit(TokenHit): FastRedirectTokenHit, SMTPTokenHit, WebBugTokenHit, + WebDavTokenHit, MySQLTokenHit, WireguardTokenHit, QRCodeTokenHit, @@ -1941,6 +1982,11 @@ class WindowsDirectoryTokenHistory(TokenHistory[WindowsDirectoryTokenHit]): hits: List[WindowsDirectoryTokenHit] = [] +class WebDavTokenHistory(TokenHistory[WebDavTokenHit]): + token_type: Literal[TokenTypes.WEBDAV] = TokenTypes.WEBDAV + hits: List[WebDavTokenHit] = [] + + class CustomBinaryTokenHistory(TokenHistory[CustomBinaryTokenHit]): token_type: Literal[TokenTypes.SIGNED_EXE] = TokenTypes.SIGNED_EXE hits: List[CustomBinaryTokenHit] = [] @@ -2041,6 +2087,7 @@ class LegacyTokenHistory(TokenHistory[LegacyTokenHit]): SlowRedirectTokenHistory, FastRedirectTokenHistory, WebBugTokenHistory, + WebDavTokenHistory, CustomBinaryTokenHistory, WireguardTokenHistory, QRCodeTokenHistory, diff --git a/canarytokens/settings.py b/canarytokens/settings.py index 986cadf23..bac2c509a 100644 --- a/canarytokens/settings.py +++ b/canarytokens/settings.py @@ -118,6 +118,10 @@ class FrontendSettings(BaseSettings): EXTEND_PASSWORD: Optional[SecretStr] = SecretStr("NoExtendPasswordFound") EXTEND_CARD_NAME: Optional[str] CLOUDFRONT_URL: Optional[HttpUrl] + CLOUDFLARE_ACCOUNT_ID: Optional[str] = "" + CLOUDFLARE_NAMESPACE: Optional[str] = "" + CLOUDFLARE_API_TOKEN: Optional[str] = "" + WEBDAV_SERVER: Optional[str] = "" AZUREAPP_ID: Optional[str] AZUREAPP_SECRET: Optional[str] # TODO: Figure out SecretStr with Azure secrets CREDIT_CARD_TOKEN_ENABLED: bool = False diff --git a/canarytokens/tokens.py b/canarytokens/tokens.py index acb4f0ef6..9684bd16d 100644 --- a/canarytokens/tokens.py +++ b/canarytokens/tokens.py @@ -32,6 +32,8 @@ TokenTypes, CreditCardV2TokenHit, CreditCardV2AdditionalInfo, + WebDavTokenHit, + WebDavAdditionalInfo, ) from canarytokens.credit_card_v2 import AnyCreditCardTrigger @@ -528,6 +530,31 @@ def _get_info_for_cssclonedsite(request: Request): } return http_general_info, src_data + @staticmethod + def _get_info_for_webdav(request: Request): + http_general_info = Canarytoken._grab_http_general_info(request=request) + client_ip = request.getHeader("X-Client-Ip") + hit_time = datetime.utcnow().strftime("%s.%f") + hit_info = { + "additional_info": WebDavAdditionalInfo( + **{ + "file_path": request.getHeader("X-Alert-Path"), + "useragent": http_general_info["useragent"], + } + ), + "geo_info": queries.get_geoinfo(ip=client_ip), + "input_channel": INPUT_CHANNEL_HTTP, + "is_tor_relay": queries.is_tor_relay(client_ip), + "src_ip": client_ip, + "time_of_hit": hit_time, + } + return WebDavTokenHit(**hit_info) + + @staticmethod + def _get_response_for_webdav(canarydrop: canarydrop.Canarydrop, request: Request): + request.setHeader("Content-Type", "image/gif") + return GIF + @staticmethod def _get_response_for_cssclonedsite( canarydrop: canarydrop.Canarydrop, request: Request diff --git a/canarytokens/webdav.py b/canarytokens/webdav.py new file mode 100644 index 000000000..50c884b4c --- /dev/null +++ b/canarytokens/webdav.py @@ -0,0 +1,57 @@ +import json +import requests +from typing import Optional +from enum import Enum +from hashlib import sha1 + +from canarytokens.settings import FrontendSettings + +settings = FrontendSettings() + +ACCOUNT_ID = settings.CLOUDFLARE_ACCOUNT_ID +NAMESPACE_ID = settings.CLOUDFLARE_NAMESPACE + + +# Matches the keys in the worker's fs.js +class FsType(str, Enum): + TESTING = "testing" + SECURITY = "security" + DEFENSE = "defense" + MEDICAL = "medical" + IT = "it" + + +def generate_webdav_password( + token_id: str, server_domain: str = settings.DOMAINS[0] +) -> str: + return sha1((server_domain + token_id).encode()).hexdigest() + + +def insert_webdav_token( + password: str, + alert_url: str, + webdav_fs_type: Optional[FsType] = None, + custom_fs: Optional[str] = None, +) -> bool: + """ + Inserts a token config into the Cloudflare KV store + Returns: True if successful, False otherwise + """ + api_cred = settings.CLOUDFLARE_API_TOKEN + value = {"token_url": alert_url} + + if webdav_fs_type is not None: + value["fs_template"] = f"{webdav_fs_type}" + if custom_fs is not None: + value["custom_fs"] = custom_fs + + fd = {"value": json.dumps(value), "metadata": "{}"} + put_url = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/storage/kv/namespaces/{NAMESPACE_ID}/values/{password}" + res = requests.put( + put_url, files=fd, headers={"Authorization": "Bearer " + api_cred} + ) + if res.status_code == 200: + return True + else: + print(res.text) + return False diff --git a/frontend/app.py b/frontend/app.py index cda7a38bd..4af12030a 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -135,6 +135,8 @@ TokenTypes, WebBugTokenRequest, WebBugTokenResponse, + WebDavTokenRequest, + WebDavTokenResponse, WindowsDirectoryTokenRequest, WindowsDirectoryTokenResponse, WireguardTokenRequest, @@ -150,6 +152,7 @@ EntraTokenStatus, LEGACY_ENTRA_STATUS_MAP, ) +from canarytokens.webdav import generate_webdav_password, insert_webdav_token, FsType from canarytokens.pdfgen import make_canary_pdf from canarytokens.queries import ( add_canary_domain, @@ -1604,6 +1607,50 @@ def _create_azure_id_token_response( ) +@create_response.register +def _( + token_request_details: WebDavTokenRequest, canarydrop: Canarydrop +) -> WebDavTokenResponse: + if not ( + frontend_settings.WEBDAV_SERVER + and frontend_settings.CLOUDFLARE_ACCOUNT_ID + and frontend_settings.CLOUDFLARE_API_TOKEN + and frontend_settings.CLOUDFLARE_NAMESPACE + ): + return JSONResponse( + { + "error_message": "This Canarytokens instance does not have the Network Folder Canarytoken enabled." + }, + status_code=400, + ) + canarydrop.webdav_fs_type = FsType(token_request_details.webdav_fs_type) + canarydrop.webdav_server = frontend_settings.WEBDAV_SERVER + queries.save_canarydrop(canarydrop=canarydrop) + canarydrop.webdav_password = generate_webdav_password( + canarydrop.canarytoken.value() + ) + queries.save_canarydrop(canarydrop=canarydrop) + insert_webdav_token( + canarydrop.webdav_password, + canarydrop.get_url([canary_http_channel]), + canarydrop.webdav_fs_type, + ) + return WebDavTokenResponse( + email=canarydrop.alert_email_recipient or "", + webhook_url=canarydrop.alert_webhook_url + if canarydrop.alert_webhook_url + else "", + token=canarydrop.canarytoken.value(), + token_url=canarydrop.get_url([canary_http_channel]), + auth_token=canarydrop.auth, + hostname=canarydrop.get_hostname(), + url_components=list(canarydrop.get_url_components()), + webdav_password=canarydrop.webdav_password, + webdav_server=canarydrop.webdav_server, + webdav_fs_type=canarydrop.webdav_fs_type, + ) + + @create_response.register def _( token_request_details: CMDTokenRequest, canarydrop: Canarydrop diff --git a/frontend/frontend.env.dist b/frontend/frontend.env.dist index 592277d22..33d329c40 100644 --- a/frontend/frontend.env.dist +++ b/frontend/frontend.env.dist @@ -38,3 +38,7 @@ CANARY_WEB_IMAGE_UPLOAD_PATH=../uploads #CANARY_CLOUDFRONT_URL= #CANARY_AZUREAPP_ID= #CANARY_AZUREAPP_SECRET= +#CANARY_CLOUDFLARE_NAMESPACE= +#CANARY_CLOUDFLARE_ACCOUNT_ID= +#CANARY_CLOUDFLARE_API_TOKEN= +#CANARY_WEBDAV_SERVER= diff --git a/frontend_vue/src/assets/token_icons/webdav.png b/frontend_vue/src/assets/token_icons/webdav.png new file mode 100644 index 000000000..4dabf535f Binary files /dev/null and b/frontend_vue/src/assets/token_icons/webdav.png differ diff --git a/frontend_vue/src/components/ModalContentHowToUse.vue b/frontend_vue/src/components/ModalContentHowToUse.vue index e82a65565..761aa85c5 100644 --- a/frontend_vue/src/components/ModalContentHowToUse.vue +++ b/frontend_vue/src/components/ModalContentHowToUse.vue @@ -74,9 +74,12 @@ li::before { content: '\f0e7'; @apply text-green-500; } - +p :deep(li) { + list-style-type: disc; +} p :deep(code) { @apply bg-green-100 px-8 py-[2px] rounded-md mt-4; overflow-wrap: anywhere; } + diff --git a/frontend_vue/src/components/ModalToken.vue b/frontend_vue/src/components/ModalToken.vue index b8f76fc95..245bfc7a1 100644 --- a/frontend_vue/src/components/ModalToken.vue +++ b/frontend_vue/src/components/ModalToken.vue @@ -271,10 +271,12 @@ async function handleGenerateToken(formValues: BaseFormValuesType) { () => (modalType.value = ModalType.NewToken) // Keep track of loaded components ).then(() => componentStack.value.push(modalType.value)); - } catch (err) { + } catch (err: any) { triggerSubmit.value = false; isGenerateTokenError.value = true; - errorMessage.value = ''; + if (err.response.data.error_message) { + errorMessage.value = err.response.data.error_message; + } } finally { triggerSubmit.value = false; } diff --git a/frontend_vue/src/components/base/BaseFormSelect.vue b/frontend_vue/src/components/base/BaseFormSelect.vue index aa82f31dd..ab7379b01 100644 --- a/frontend_vue/src/components/base/BaseFormSelect.vue +++ b/frontend_vue/src/components/base/BaseFormSelect.vue @@ -1,7 +1,7 @@