-
Notifications
You must be signed in to change notification settings - Fork 623
[SECURITY] Add input validation and CSP headers #1834
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kirtimanmishrazipstack
merged 13 commits into
main
from
security/input-validation-csp-cookie-hardening
Mar 24, 2026
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
24397c6
[SECURITY] Add input validation, CSP headers, and secure cookie defaults
hari-kuriakose 35f6eaf
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] d9c5770
Apply suggestion from @coderabbitai[bot]
hari-kuriakose 1a46788
Apply suggestion from @coderabbitai[bot]
hari-kuriakose 423c2e9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 9a62502
Apply suggestions from code review
hari-kuriakose d595efd
Address PR review comments for input validation and CSP hardening
vishnuszipstack cb8bb89
Merge branch 'main' into security/input-validation-csp-cookie-hardening
vishnuszipstack 8b648d2
Address PR review comments: fix nginx CSP syntax, tighten policies
vishnuszipstack 141dd5c
Harden input sanitizer: catch unclosed tags and data:/vbscript: URIs
vishnuszipstack e9480ad
Fix data: URI false positives on ordinary English text
vishnuszipstack 1b88282
Merge branch 'main' into security/input-validation-csp-cookie-hardening
kirtimanmishrazipstack 4107b53
Merge branch 'main' into security/input-validation-csp-cookie-hardening
kirtimanmishrazipstack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| from django.http import HttpRequest, HttpResponse | ||
| from django.utils.deprecation import MiddlewareMixin | ||
|
|
||
|
|
||
| class ContentSecurityPolicyMiddleware(MiddlewareMixin): | ||
| """Middleware to add Content-Security-Policy header to all responses. | ||
|
|
||
| The policy is restrictive by default. The inline script in login.html | ||
| is allowed via a SHA-256 hash rather than 'unsafe-inline' to maintain | ||
| strong XSS protection. 'unsafe-inline' is only used for style-src | ||
| because the login page uses inline <style> blocks. | ||
|
|
||
| Uses response.setdefault() so that route-specific CSP policies set by | ||
| views or earlier middleware are not overwritten. | ||
| """ | ||
|
|
||
| # SHA-256 hash of the inline script in login.html (form submit spinner). | ||
| # If that script changes, regenerate with: | ||
| # python -c "import hashlib,base64; ..." | ||
|
kirtimanmishrazipstack marked this conversation as resolved.
kirtimanmishrazipstack marked this conversation as resolved.
|
||
| _SCRIPT_HASH = "sha256-GES82NvXpRYmVFDKv6vRHx2c7xuv8mgUzUaP7heKeFY=" | ||
|
|
||
| def process_response( | ||
| self, request: HttpRequest, response: HttpResponse | ||
|
Check warning on line 23 in backend/middleware/content_security_policy.py
|
||
| ) -> HttpResponse: | ||
| response.setdefault( | ||
| "Content-Security-Policy", | ||
| ( | ||
| "default-src 'self'; " | ||
| f"script-src 'self' '{self._SCRIPT_HASH}'; " | ||
| "style-src 'self' 'unsafe-inline'; " | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| "img-src 'self'; " | ||
| "font-src 'self'; " | ||
| "connect-src 'self'; " | ||
| "object-src 'none'; " | ||
| "frame-ancestors 'none'; " | ||
| "base-uri 'self'; " | ||
| "form-action 'self'" | ||
| ), | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| ) | ||
| return response | ||
|
kirtimanmishrazipstack marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import re | ||
|
|
||
| from rest_framework.serializers import ValidationError | ||
|
|
||
| # Pattern to detect HTML/script tags (closed tags and unclosed tags starting with a letter) | ||
| # The second alternative catches unclosed tags like "<script" or "<img src=x" that could | ||
| # be completed by adjacent content in non-React rendering contexts (emails, PDFs, logs) | ||
| HTML_TAG_PATTERN = re.compile(r"<[^>]*>|<[a-zA-Z/!]") | ||
| # Pattern to detect dangerous URI protocols: javascript:, vbscript:, and data: URIs. | ||
| # data: URIs are only matched when followed by a MIME type (word/word) to avoid | ||
| # false positives on ordinary English text like "Input data: JSON format". | ||
| JS_PROTOCOL_PATTERN = re.compile( | ||
| r"(?:javascript|vbscript)\s*:|data\s*:\s*\w+/\w+", re.IGNORECASE | ||
| ) | ||
|
kirtimanmishrazipstack marked this conversation as resolved.
|
||
| # Pattern to detect event handlers using a vetted list of DOM event names. | ||
| # This avoids false positives on benign words like "connection=", "onboarding=", etc. | ||
| _DOM_EVENTS = ( | ||
| "abort|blur|change|click|close|contextmenu|copy|cut|dblclick|drag|dragend|" | ||
| "dragenter|dragleave|dragover|dragstart|drop|error|focus|focusin|focusout|" | ||
| "input|invalid|keydown|keypress|keyup|load|mousedown|mouseenter|mouseleave|" | ||
| "mousemove|mouseout|mouseover|mouseup|paste|pointerdown|pointerenter|" | ||
| "pointerleave|pointermove|pointerout|pointerover|pointerup|reset|resize|" | ||
| "scroll|select|submit|toggle|touchcancel|touchend|touchmove|touchstart|" | ||
| "unload|wheel" | ||
| ) | ||
| EVENT_HANDLER_PATTERN = re.compile(rf"\bon({_DOM_EVENTS})\s*=", re.IGNORECASE) | ||
|
kirtimanmishrazipstack marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def validate_no_html_tags(value: str, field_name: str = "This field") -> str: | ||
| """Reject values containing HTML/script tags.""" | ||
| if HTML_TAG_PATTERN.search(value): | ||
| raise ValidationError(f"{field_name} must not contain HTML or script tags.") | ||
| if JS_PROTOCOL_PATTERN.search(value): | ||
| raise ValidationError(f"{field_name} must not contain dangerous URI protocols.") | ||
| if EVENT_HANDLER_PATTERN.search(value): | ||
| raise ValidationError(f"{field_name} must not contain event handler attributes.") | ||
| return value | ||
|
|
||
|
|
||
| def validate_name_field(value: str, field_name: str = "This field") -> str: | ||
| """Validate name/identifier fields - no HTML tags, strip whitespace.""" | ||
| value = value.strip() | ||
| if not value: | ||
| raise ValidationError(f"{field_name} must not be empty.") | ||
| return validate_no_html_tags(value, field_name) | ||
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import pytest | ||
| from rest_framework.serializers import ValidationError | ||
|
|
||
| from utils.input_sanitizer import validate_name_field, validate_no_html_tags | ||
|
|
||
|
|
||
| class TestValidateNoHtmlTags: | ||
| def test_clean_input_passes(self): | ||
| assert validate_no_html_tags("Hello World") == "Hello World" | ||
|
|
||
| def test_allows_normal_special_chars(self): | ||
| assert ( | ||
| validate_no_html_tags("My workflow (v2), test - final") | ||
| == "My workflow (v2), test - final" | ||
| ) | ||
|
|
||
| def test_allows_numbers_and_punctuation(self): | ||
| assert validate_no_html_tags("Test 123 & more!") == "Test 123 & more!" | ||
|
|
||
| def test_rejects_script_tag(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_no_html_tags("<script>alert(1)</script>") | ||
|
|
||
| def test_rejects_img_tag(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_no_html_tags('<img src=x onerror=alert(1)>') | ||
|
|
||
| def test_rejects_div_tag(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_no_html_tags("<div>content</div>") | ||
|
|
||
| def test_rejects_self_closing_tag(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_no_html_tags("<br/>") | ||
|
|
||
| def test_rejects_unclosed_script_tag(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_no_html_tags("<script") | ||
|
|
||
| def test_rejects_unclosed_img_tag(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_no_html_tags("<img src=x onerror") | ||
|
|
||
| def test_allows_less_than_with_number(self): | ||
| assert validate_no_html_tags("a < 3") == "a < 3" | ||
|
|
||
| def test_rejects_javascript_protocol(self): | ||
| with pytest.raises(ValidationError, match="must not contain dangerous URI protocols"): | ||
| validate_no_html_tags("javascript:alert(1)") | ||
|
|
||
| def test_rejects_javascript_protocol_with_spaces(self): | ||
| with pytest.raises(ValidationError, match="must not contain dangerous URI protocols"): | ||
| validate_no_html_tags("javascript :alert(1)") | ||
|
|
||
| def test_rejects_javascript_protocol_case_insensitive(self): | ||
| with pytest.raises(ValidationError, match="must not contain dangerous URI protocols"): | ||
| validate_no_html_tags("JAVASCRIPT:alert(1)") | ||
|
|
||
| def test_rejects_data_uri(self): | ||
| with pytest.raises(ValidationError, match="must not contain dangerous URI protocols"): | ||
| validate_no_html_tags("data:text/html,alert(1)") | ||
|
|
||
| def test_rejects_data_uri_with_mime_type(self): | ||
| with pytest.raises(ValidationError, match="must not contain dangerous URI protocols"): | ||
| validate_no_html_tags("data:application/javascript,code") | ||
|
|
||
| def test_allows_benign_data_colon_in_text(self): | ||
| assert validate_no_html_tags("Input data: JSON format") == "Input data: JSON format" | ||
|
|
||
| def test_allows_data_colon_without_mime(self): | ||
| assert validate_no_html_tags("my data: 100 records") == "my data: 100 records" | ||
|
|
||
| def test_rejects_vbscript_protocol(self): | ||
| with pytest.raises(ValidationError, match="must not contain dangerous URI protocols"): | ||
| validate_no_html_tags("vbscript:MsgBox") | ||
|
|
||
| def test_rejects_event_handler(self): | ||
| with pytest.raises( | ||
| ValidationError, match="must not contain event handler attributes" | ||
| ): | ||
| validate_no_html_tags("onclick=alert(1)") | ||
|
|
||
| def test_rejects_event_handler_with_spaces(self): | ||
| with pytest.raises( | ||
| ValidationError, match="must not contain event handler attributes" | ||
| ): | ||
| validate_no_html_tags("onerror =alert(1)") | ||
|
|
||
| def test_rejects_event_handler_case_insensitive(self): | ||
| with pytest.raises( | ||
| ValidationError, match="must not contain event handler attributes" | ||
| ): | ||
| validate_no_html_tags("ONLOAD=alert(1)") | ||
|
|
||
| def test_allows_benign_connection_text(self): | ||
| assert validate_no_html_tags("connection=ok") == "connection=ok" | ||
|
|
||
| def test_allows_onboarding_text(self): | ||
| assert validate_no_html_tags("onboarding = enabled") == "onboarding = enabled" | ||
|
|
||
| def test_allows_oncall_text(self): | ||
| assert validate_no_html_tags("oncall = primary") == "oncall = primary" | ||
|
|
||
| def test_allows_condition_text(self): | ||
| assert validate_no_html_tags("condition = good") == "condition = good" | ||
|
|
||
| def test_rejects_event_handler_after_semicolon(self): | ||
| with pytest.raises( | ||
| ValidationError, match="must not contain event handler attributes" | ||
| ): | ||
| validate_no_html_tags("foo;onclick=alert(1)") | ||
|
|
||
| def test_rejects_event_handler_after_ampersand(self): | ||
| with pytest.raises( | ||
| ValidationError, match="must not contain event handler attributes" | ||
| ): | ||
| validate_no_html_tags("foo&onerror=alert(1)") | ||
|
|
||
| def test_custom_field_name_in_error(self): | ||
| with pytest.raises(ValidationError, match="Workflow name"): | ||
| validate_no_html_tags("<script>", field_name="Workflow name") | ||
|
|
||
|
|
||
| class TestValidateNameField: | ||
| def test_clean_name_passes(self): | ||
| assert validate_name_field("My Workflow") == "My Workflow" | ||
|
|
||
| def test_strips_whitespace(self): | ||
| assert validate_name_field(" hello ") == "hello" | ||
|
|
||
| def test_rejects_empty_after_strip(self): | ||
| with pytest.raises(ValidationError, match="must not be empty"): | ||
| validate_name_field(" ") | ||
|
|
||
| def test_rejects_html_tags(self): | ||
| with pytest.raises(ValidationError, match="must not contain HTML or script tags"): | ||
| validate_name_field("<script>alert(1)</script>") | ||
|
|
||
| def test_allows_hyphens_and_underscores(self): | ||
| assert validate_name_field("my-workflow_v2") == "my-workflow_v2" | ||
|
|
||
| def test_allows_periods(self): | ||
| assert validate_name_field("config.v2") == "config.v2" | ||
|
|
||
| def test_allows_parentheses_and_commas(self): | ||
| assert validate_name_field("Test (v2), final") == "Test (v2), final" | ||
|
|
||
| def test_custom_field_name_in_error(self): | ||
| with pytest.raises(ValidationError, match="Tool name"): | ||
| validate_name_field(" ", field_name="Tool name") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.