Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 11 additions & 20 deletions forms_pro/api/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from forms_pro.api.user import get_user
from forms_pro.forms_pro.doctype.form.form import Form
from forms_pro.utils.constants import FORMS_PRO_SYSTEM_FIELDNAMES, UNSUPPORTED_FRAPPE_FIELDTYPES


class FormSharedWithResponse(BaseModel):
Expand Down Expand Up @@ -36,9 +37,11 @@ def is_login_required(route: str) -> bool:
return bool(login_enabled)


@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_form_by_route(route: str) -> dict:
form_id = frappe.db.get_value("Form", {"route": route}, pluck="name")
if not form_id:
frappe.throw(_("Form not found"), frappe.DoesNotExistError)
return get_form(form_id)


Expand Down Expand Up @@ -230,25 +233,13 @@ def get_doctype_list() -> list[str]:
)


@frappe.whitelist(allow_guest=True)
def get_doctype_fields(doctype: str) -> dict:
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def get_doctype_fields(doctype: str) -> list:
doctype = frappe.get_doc("DocType", doctype)
fields = doctype.fields

FIELDTYPES_TO_REMOVE = [
"Section Break",
"HTML",
"Button",
"Column Break",
"Tab Break",
"Barcode",
"Dynamic Link",
"Fold",
fields = [
field
for field in doctype.fields
if field.fieldtype not in UNSUPPORTED_FRAPPE_FIELDTYPES
and field.fieldname not in FORMS_PRO_SYSTEM_FIELDNAMES
]
Comment on lines +236 to 244
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guest access here can expose schema for arbitrary DocTypes.

get_doctype_fields is guest-accessible and accepts any doctype name, but it does not enforce a permission or publish-scope check before returning field metadata. That enables unauthenticated schema enumeration outside intended form contexts.

🔒 Suggested guard (limit guest access to published-form doctypes)
 `@frappe.whitelist`(allow_guest=True)  # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
 def get_doctype_fields(doctype: str) -> dict:
+    if frappe.session.user == "Guest":
+        is_public_doctype = frappe.db.exists(
+            "Form",
+            {"linked_doctype": doctype, "is_published": 1},
+        )
+        if not is_public_doctype:
+            frappe.throw(_("You do not have access to this doctype"), frappe.PermissionError)
+    elif not frappe.has_permission("DocType", "read", doctype):
+        frappe.throw(_("You do not have read access to this doctype"), frappe.PermissionError)
+
     doctype = frappe.get_doc("DocType", doctype)
     fields = [
         field
         for field in doctype.fields
         if field.fieldtype not in UNSUPPORTED_FRAPPE_FIELDTYPES
         and field.fieldname not in FORMS_PRO_SYSTEM_FIELDNAMES
     ]
     return fields


FIELDS_TO_REMOVE = ["fp_submission_status", "fp_linked_form"]

fields = [field for field in fields if field.fieldtype not in FIELDTYPES_TO_REMOVE]
fields = [field for field in fields if field.fieldname not in FIELDS_TO_REMOVE]

return fields
116 changes: 111 additions & 5 deletions forms_pro/api/submission.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from datetime import datetime
from typing import Any

Expand Down Expand Up @@ -32,11 +33,98 @@ def parse_datetime(cls, v: Any) -> datetime:
raise ValueError(f"Invalid datetime value: {v}")


@frappe.whitelist(allow_guest=True)
def _coerce_field_value(value: Any, fieldtype: str) -> Any:
"""Coerce a submitted value to its comparable type, matching frontend conditionals.ts logic."""
if value is None or value == "":
return None
# Matches isBoolean types in the frontend registry (Switch, Checkbox)
if fieldtype in ("Switch", "Checkbox"):
return bool(value)
if fieldtype == "Number":
try:
return float(value)
except (TypeError, ValueError):
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return str(value)
Comment on lines +36 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat zero-length collections as empty answers.

forms_pro/forms_pro/doctype/form_field/form_field.py includes Table as a supported field type, but [] currently falls through to str(value) and the required check on Line 116 only rejects None/"". That lets submitted responses bypass required table rows, and the same root cause also makes is_empty / is_not_empty misclassify empty collections.

🩹 Suggested fix
+def _is_empty_value(value: Any) -> bool:
+    return value is None or value == "" or (
+        isinstance(value, (list, tuple, dict, set)) and len(value) == 0
+    )
+
+
 def _coerce_field_value(value: Any, fieldtype: str) -> Any:
     """Coerce a submitted value to its comparable type, matching frontend conditionals.ts logic."""
-    if value is None or value == "":
+    if _is_empty_value(value):
         return None
+    if fieldtype == "Table":
+        return value
     # Matches isBoolean types in the frontend registry (Switch, Checkbox)
     if fieldtype in ("Switch", "Checkbox"):
         return bool(value)
     if fieldtype == "Number":
         try:
             return float(value)
         except (TypeError, ValueError):
             return None
     return str(value)
...
-        if is_required and (value is None or value == ""):
+        if is_required and _is_empty_value(value):
             errors.append(_("{0} is required").format(field.label))

Also applies to: 115-116

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@forms_pro/api/submission.py` around lines 36 - 48, The coercion currently
converts empty collections (e.g., [] for Table) to a string, so required checks
and empty/ non-empty conditionals misclassify them; update _coerce_field_value
to detect iterable/collection types (list, tuple, set, dict, etc.) and return
None when their length is zero (treat zero-length collections as empty answers)
while preserving non-empty collections, and also update the required-answer
check logic used by is_empty/is_not_empty (the code that currently only treats
None/"" as empty) to consider zero-length collections as empty as well;
reference _coerce_field_value and the is_empty / is_not_empty / required-check
code paths so both coercion and validation consistently treat empty collections
as empty.



def _evaluate_conditions(conditions: list[dict], form_data: dict, field_map: dict) -> bool:
"""Evaluate AND-joined conditions against submitted form data."""
for condition in conditions:
fieldname = condition.get("fieldname")
operator = condition.get("operator")
expected = condition.get("value")
field = field_map.get(fieldname)
if not field:
return False
actual = _coerce_field_value(form_data.get(fieldname), field.fieldtype)
# Coerce the condition's expected value through the same function so
# types match — avoids str(True)=="True" vs "true" mismatches and
# str(3.0)=="3.0" vs "3" mismatches for Number fields.
expected_coerced = _coerce_field_value(expected, field.fieldtype)
if operator == "Is" and actual != expected_coerced:
return False
if operator == "Is Not" and actual == expected_coerced:
return False
if operator == "Is Empty" and actual is not None and actual != "":
return False
if operator == "Is Not Empty" and (actual is None or actual == ""):
return False
return True
Comment on lines +51 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate decoded conditional logic before evaluating it.

Line 99 assumes json.loads() returned a dict, and _evaluate_conditions() assumes conditions is a list of dicts. Valid JSON like [] or {"conditions": "oops"} will blow up here, and an unsupported operator currently falls through as satisfied because none of these branches return False.

🛡️ Suggested fix
 def _evaluate_conditions(conditions: list[dict], form_data: dict, field_map: dict) -> bool:
     """Evaluate AND-joined conditions against submitted form data."""
+    if not isinstance(conditions, list):
+        return False
+
     for condition in conditions:
+        if not isinstance(condition, dict):
+            return False
+
         fieldname = condition.get("fieldname")
         operator = condition.get("operator")
         expected = condition.get("value")
         field = field_map.get(fieldname)
         if not field:
             return False
         actual = _coerce_field_value(form_data.get(fieldname), field.fieldtype)
         expected_coerced = _coerce_field_value(expected, field.fieldtype)
-        if operator == "is" and actual != expected_coerced:
-            return False
-        if operator == "is_not" and actual == expected_coerced:
-            return False
-        if operator == "is_empty" and actual is not None and actual != "":
-            return False
-        if operator == "is_not_empty" and (actual is None or actual == ""):
-            return False
+        if operator == "is":
+            if actual != expected_coerced:
+                return False
+        elif operator == "is_not":
+            if actual == expected_coerced:
+                return False
+        elif operator == "is_empty":
+            if actual is not None and actual != "":
+                return False
+        elif operator == "is_not_empty":
+            if actual is None or actual == "":
+                return False
+        else:
+            return False
     return True
...
             try:
                 logic = json.loads(other.conditional_logic)
             except (json.JSONDecodeError, TypeError):
                 continue
+            if not isinstance(logic, dict):
+                continue

Also applies to: 94-102

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@forms_pro/api/submission.py` around lines 51 - 73, The decoded conditional
payload must be validated before use and _evaluate_conditions must defensively
handle unexpected shapes and operators: ensure the caller verifies
json.loads(...) yields a dict/expected shape and that the "conditions" value is
a list of dicts (or bail/return False) before calling _evaluate_conditions;
inside _evaluate_conditions validate that each condition is a dict, that
fieldname/operator exist and are the correct types, and treat any unrecognized
operator as failing the condition (return False) instead of falling through as
satisfied so unsupported operators don't silently pass.



def _validate_form_response(form: "Form", form_data: dict) -> None:
"""
Validate required and conditionally-required fields server-side.

Mirrors the shouldFieldBeVisible / shouldFieldBeRequired logic in
frontend/src/utils/conditionals.ts so that direct API calls cannot
bypass frontend validation.
"""
field_map = {f.fieldname: f for f in form.fields}
errors: list[str] = []

for field in form.fields:
is_visible = not field.hidden
is_required = bool(field.reqd)

for other in form.fields:
if not other.conditional_logic:
continue
try:
logic = json.loads(other.conditional_logic)
except (json.JSONDecodeError, TypeError):
continue

if logic.get("target_field") != field.fieldname:
continue

conditions_met = _evaluate_conditions(logic.get("conditions", []), form_data, field_map)
if conditions_met:
action = logic.get("action")
if action == "Show Field":
is_visible = True
elif action == "Hide Field":
is_visible = False
elif action == "Require Answer":
is_required = True

if not is_visible:
continue

value = form_data.get(field.fieldname)
if is_required and (value is None or value == ""):
errors.append(_("{0} is required").format(field.label))

if errors:
frappe.throw("\n".join(errors), frappe.ValidationError)


@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def submit_form_response(
form_id: str,
form_data: list[dict],
submission_status: SubmissionStatus = SubmissionStatus.SUBMITTED,
submission_status: str = SubmissionStatus.SUBMITTED.value,
) -> str:
"""
Submit a form response
Expand All @@ -49,6 +137,14 @@ def submit_form_response(
Returns:
The name of the submission
"""
try:
status = SubmissionStatus(submission_status)
except ValueError:
frappe.throw(
_("Invalid submission status: {0}").format(submission_status),
frappe.ValidationError,
)

try:
form: Form = frappe.get_doc("Form", form_id)
linked_doctype = form.linked_doctype
Expand All @@ -59,12 +155,22 @@ def submit_form_response(
frappe.PermissionError,
)

form_data_dict = {item["fieldname"]: item["value"] for item in form_data}

# Whitelist to declared form fields only — prevents injecting system fields
# like owner, docstatus, etc. into the linked DocType.
allowed_fieldnames = {f.fieldname for f in form.fields}
form_data_dict = {k: v for k, v in form_data_dict.items() if k in allowed_fieldnames}
Comment on lines +158 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate form_data item shape before building the dict.

Because this endpoint is allow_guest=True, payloads like [1] or [{"fieldname": "x"}] currently raise TypeError / KeyError here and bubble out as 500s. Convert malformed items into a ValidationError before building form_data_dict.

🧪 Suggested fix
-        form_data_dict = {item["fieldname"]: item["value"] for item in form_data}
+        form_data_dict: dict[str, Any] = {}
+        for item in form_data:
+            if (
+                not isinstance(item, dict)
+                or not isinstance(item.get("fieldname"), str)
+                or "value" not in item
+            ):
+                frappe.throw(_("Invalid form data payload"), frappe.ValidationError)
+
+            form_data_dict[item["fieldname"]] = item["value"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@forms_pro/api/submission.py` around lines 158 - 163, The code builds
form_data_dict from form_data without validating each item shape, causing
TypeError/KeyError for malformed guest payloads; before the dict comprehension
that creates form_data_dict (and before filtering against allowed_fieldnames),
iterate/validate each item in form_data to ensure it's a mapping with both
"fieldname" and "value" keys (and that "fieldname" is a string), and if any item
is malformed raise a ValidationError; then proceed to build form_data_dict and
filter by allowed_fieldnames (from form.fields) as existing code does.


if status == SubmissionStatus.SUBMITTED:
_validate_form_response(form, form_data_dict)

submission = frappe.new_doc(linked_doctype)
for data in form_data:
submission.set(data["fieldname"], data["value"])
for fieldname, value in form_data_dict.items():
submission.set(fieldname, value)
Comment on lines +158 to +170
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reject unexpected field names before calling submission.set().

This endpoint is allow_guest=True, but it currently writes every client-supplied fieldname into the linked DocType. A direct API caller can populate fields that are not part of the form at all. Filter against {f.fieldname for f in form.fields} and throw on extras before inserting.

🔒 Suggested hardening
-        form_data_dict = {item["fieldname"]: item["value"] for item in form_data}
+        allowed_fieldnames = {field.fieldname for field in form.fields}
+        unexpected_fieldnames = [
+            item["fieldname"] for item in form_data if item["fieldname"] not in allowed_fieldnames
+        ]
+        if unexpected_fieldnames:
+            frappe.throw(
+                _("Invalid field(s): {0}").format(", ".join(unexpected_fieldnames)),
+                frappe.ValidationError,
+            )
+
+        form_data_dict = {
+            item["fieldname"]: item["value"]
+            for item in form_data
+            if item["fieldname"] in allowed_fieldnames
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
form_data_dict = {item["fieldname"]: item["value"] for item in form_data}
if status == SubmissionStatus.SUBMITTED:
_validate_form_response(form, form_data_dict)
submission = frappe.new_doc(linked_doctype)
for data in form_data:
submission.set(data["fieldname"], data["value"])
for fieldname, value in form_data_dict.items():
submission.set(fieldname, value)
allowed_fieldnames = {field.fieldname for field in form.fields}
unexpected_fieldnames = [
item["fieldname"] for item in form_data if item["fieldname"] not in allowed_fieldnames
]
if unexpected_fieldnames:
frappe.throw(
_("Invalid field(s): {0}").format(", ".join(unexpected_fieldnames)),
frappe.ValidationError,
)
form_data_dict = {
item["fieldname"]: item["value"]
for item in form_data
if item["fieldname"] in allowed_fieldnames
}
if status == SubmissionStatus.SUBMITTED:
_validate_form_response(form, form_data_dict)
submission = frappe.new_doc(linked_doctype)
for fieldname, value in form_data_dict.items():
submission.set(fieldname, value)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@forms_pro/api/submission.py` around lines 153 - 160, The code currently
writes every client-supplied key from form_data_dict into the linked DocType via
submission.set, so reject unexpected fieldnames by building an allowed set from
the declared form fields (e.g., allowed = {f.fieldname for f in form.fields})
and compare form_data_dict.keys() against it; if there are any extras,
raise/return an error before creating or populating the submission (do not call
submission.set for unknown fields) and otherwise only iterate over the filtered
keys when assigning values; apply this check before or immediately after the
_validate_form_response call when status == SubmissionStatus.SUBMITTED to ensure
guest requests cannot inject fields into linked_doctype.


submission.fp_linked_form = form_id
submission.fp_submission_status = submission_status.value
submission.fp_submission_status = status.value
submission.insert(ignore_permissions=True, ignore_mandatory=True)

# Share the submission with the owner
Expand Down
49 changes: 29 additions & 20 deletions forms_pro/forms_pro/doctype/form_field/form_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@
# import frappe
from frappe.model.document import Document

# Maps Forms Pro field types to Frappe CustomField fieldtypes.
# When adding a new field type:
# 1. Add the option to form_field.json → DF.Literal regenerates automatically
# 2. Add an entry here
# 3. Add an entry to FIELD_TYPE_DEFINITIONS in frontend/src/config/fieldTypes.ts
FORM_TO_FRAPPE_FIELDTYPE: dict[str, dict] = {
"Attach": {"fieldtype": "Attach"},
"Data": {"fieldtype": "Data"},
"Number": {"fieldtype": "Int"},
"Email": {"fieldtype": "Data", "options": "Email"},
"Date": {"fieldtype": "Date"},
"Date Time": {"fieldtype": "Datetime"},
"Date Range": {"fieldtype": "Data"},
"Time Picker": {"fieldtype": "Time"},
"Password": {"fieldtype": "Password"},
"Select": {"fieldtype": "Select"},
"Phone": {"fieldtype": "Phone"},
"Switch": {"fieldtype": "Check"},
"Textarea": {"fieldtype": "Text"},
"Text Editor": {"fieldtype": "Text Editor"},
"Link": {"fieldtype": "Link"},
"Checkbox": {"fieldtype": "Check"},
"Rating": {"fieldtype": "Rating"},
"Table": {"fieldtype": "Table"},
}


class FormField(Document):
# begin: auto-generated types
Expand Down Expand Up @@ -49,30 +75,13 @@ class FormField(Document):

@property
def to_frappe_field(self) -> dict:
_fieldtype = self.fieldtype

if self.fieldtype == "Email":
_fieldtype = "Data"
self.options = "Email"
elif self.fieldtype == "Number":
_fieldtype = "Int"
elif self.fieldtype == "Date Time":
_fieldtype = "Datetime"
elif self.fieldtype == "Date Range":
_fieldtype = "Data"
elif self.fieldtype == "Time Picker":
_fieldtype = "Time"
elif self.fieldtype == "Switch" or self.fieldtype == "Checkbox":
_fieldtype = "Check"
elif self.fieldtype == "Textarea":
_fieldtype = "Text"

mapping = FORM_TO_FRAPPE_FIELDTYPE.get(self.fieldtype, {})
return {
"fieldname": self.fieldname,
"fieldtype": _fieldtype,
"fieldtype": mapping.get("fieldtype", self.fieldtype),
"label": self.label,
"reqd": self.reqd,
"options": self.options,
"options": mapping.get("options", self.options),
"description": self.description,
"default": self.default,
}
Loading
Loading