Skip to content
Draft
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,74 @@ install packages api key: 12345
See [discussion here](https://github.com/SuffolkLITLab/docassemble-AssemblyLine/issues/69)


# Answer Set Import Safety Configuration

Answer set JSON imports are intentionally restricted to reduce risk from malformed and malicious payloads.

Default behavior:
- Plain JSON values are imported by default, and object reconstruction is allowed only for allowlisted DAObject classes.
- Top-level variable names must match `^[A-Za-z][A-Za-z0-9_]*$`.
- Internal/protected variable names are blocked.
- If `answer set import allowed variables` is not set, imports use a denylist-only policy for backwards compatibility.
- Object payloads can be imported when classes are allowlisted; by default, known `docassemble.base` and `docassemble.AssemblyLine` DAObject descendants are allowed.
Comment on lines +83 to +88
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

README says that when answer set import allowed variables is unset, imports use a denylist-only policy for backwards compatibility. However, the implementation also restricts imports to variables detected in the target interview (via _get_target_interview_variables) when available, which is stricter than denylist-only. Please update this section to match the actual default behavior (or adjust the code to match the documented policy).

Copilot uses AI. Check for mistakes.

Default import limits (`assembly line: answer set import limits`):
- `max bytes`: `1048576` (1 MB)
- `max depth`: `40`
- `max keys`: `20000`
- `max list items`: `5000`
- `max string length`: `200000`
- `max number abs`: `1000000000000000` (`10**15`)

Final allowlist/config policy:
- Default allowlist: unset (`answer set import allowed variables` omitted), to avoid breaking existing interviews unexpectedly.
- Recommended production policy: set an explicit allowlist to only shared/reusable variables in your jurisdiction.
- `answer set import allow objects` defaults to `true`; set it to `false` if you want strict plain-JSON-only imports.
- `answer set import allowed object classes` can extend the default DAObject class allowlist with explicit additional class paths.
- Additional classes in `answer set import allowed object classes` apply to object envelopes at any depth (top-level variables and nested descendants).
- `answer set import remap known classes` defaults to `true`; this safely maps known class basenames from other packages (such as playground exports) onto official allowlisted classes.
- `answer set import class remap` can define explicit basename-to-class mappings for additional controlled remaps.

Example hardened configuration:

```yaml
assembly line:
enable answer sets: true
enable answer set imports: true
answer set import require signed: false
answer set import allow objects: true
answer set import remap known classes: true
answer set import limits:
max bytes: 1048576
max depth: 40
max keys: 20000
max list items: 5000
max string length: 200000
max number abs: 1000000000000000
answer set import allowed variables:
- users_name
- users_address
- users_phone_number
- users_email
- household_size
answer set import allowed object classes:
- docassemble.AssemblyLine.al_general.ALIndividual
- docassemble.AssemblyLine.al_general.ALPeopleList
- docassemble.AssemblyLine.al_general.ALAddress
answer set import class remap:
ALIndividual: docassemble.AssemblyLine.al_general.ALIndividual
ALPeopleList: docassemble.AssemblyLine.al_general.ALPeopleList
```

Notes:
- Keeping `answer set import require signed: false` matches current compatibility-first behavior; unsigned imports still pass strict structural validation.
- If your environment can manage signing keys, set `answer set import require signed: true` to require signed payloads.
- Class allowlisting uses full dotted class names (exact match), not wildcard patterns.
- Playground-authored classes usually need explicit allowlisting, e.g. `docassemble.playground1.al_general.ALIndividual`.
- If a playground package name changes across environments (for example `playground1` to `playground2`), update `answer set import allowed object classes` to match the runtime class path.
- With `answer set import remap known classes: true`, exports that use known class basenames (for example `docassemble.playground1.al_general.ALIndividual`) can be remapped to official allowlisted classes without instantiating the playground class.


# ALDocument class

## Purpose
Expand Down
8 changes: 4 additions & 4 deletions docassemble/AssemblyLine/data/questions/al_document.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ code: |
key=action_argument("key"),
preferred_formats=preferred_formats,
)
email_arg = action_argument('email')
email_arg = action_argument("email")
if isinstance(email_arg, list):
email_str = ', '.join(email_arg)
email_str = ", ".join(email_arg)
else:
email_str = str(email_arg)
if email_success:
Expand Down Expand Up @@ -72,9 +72,9 @@ code: |
key=action_argument("key"),
preferred_formats=preferred_formats,
)
email_arg = action_argument('email')
email_arg = action_argument("email")
if isinstance(email_arg, list):
email_str = ', '.join(email_arg)
email_str = ", ".join(email_arg)
else:
email_str = str(email_arg)
if email_success:
Expand Down
32 changes: 26 additions & 6 deletions docassemble/AssemblyLine/data/questions/al_saved_sessions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ code: |
# HACK
# Create a placeholder value to avoid playground errors
al_sessions_snapshot_results = DAEmpty()
al_sessions_last_import_report = {"accepted": [], "rejected": [], "warnings": []}
---
initial: True
code: |
Expand Down Expand Up @@ -217,18 +218,39 @@ back button: False
---
id: al sessions load status
continue button field: al_sessions_load_status
comment: |
#TODO There's no error handling yet so this might be a lie
question: |
% if al_sessions_snapshot_results:
Your answer set was loaded
% else:
Your answer set was not loaded. You can try again.
% endif
subquestion: |
% if defined('al_sessions_last_import_report'):
% if al_sessions_last_import_report.get('warnings'):
${ collapse_template(al_sessions_import_warnings_template) }
% endif
% if al_sessions_last_import_report.get('rejected'):
${ collapse_template(al_sessions_import_rejected_template) }
% endif
% endif

Tap "next" to keep answering any unanswered questions and finish the interview.
back button: False
---
template: al_sessions_import_warnings_template
subject: Import warnings
content: |
% for warning in al_sessions_last_import_report.get('warnings', []):
* ${ warning }
% endfor
---
template: al_sessions_import_rejected_template
subject: Variables skipped during import
content: |
% for item in al_sessions_last_import_report.get('rejected', []):
* `${ item.get('path', '?') }`: ${ item.get('reason', 'unknown reason') }
% endfor
Comment on lines +250 to +252
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

al_sessions_import_rejected_template renders a bullet for every rejected item. With the current limits (max_keys up to 20k), this could generate an extremely large page and slow down rendering for a malicious/accidental large upload. Consider truncating the displayed list (e.g., first N rejections) and showing a summary count, while keeping the full report available for debugging/logging if needed.

Suggested change
% for item in al_sessions_last_import_report.get('rejected', []):
* `${ item.get('path', '?') }`: ${ item.get('reason', 'unknown reason') }
% endfor
% for item in al_sessions_last_import_report.get('rejected', [])[:50]:
* `${ item.get('path', '?') }`: ${ item.get('reason', 'unknown reason') }
% endfor
% if len(al_sessions_last_import_report.get('rejected', [])) > 50:
* ${ len(al_sessions_last_import_report.get('rejected', [])) - 50 } more variables were skipped and are not shown here.
% endif

Copilot uses AI. Check for mistakes.
---
question: |
Upload a JSON file
subquestion: |
Expand All @@ -239,11 +261,9 @@ fields:
accept: |
"application/json, text/json, text/*, .json"
validation code: |
try:
json.loads(al_sessions_json_file.slurp())
except:
validation_error("Upload a file with valid JSON")
is_valid_json(al_sessions_json_file.slurp())
---
code: |
al_sessions_snapshot_results = load_interview_json(al_sessions_json_file.slurp())
al_sessions_last_import_report = get_last_import_report()
al_sessions_import_json = True
8 changes: 7 additions & 1 deletion docassemble/AssemblyLine/data/questions/al_settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,10 @@ code: |
---
code: |
# Can be an exact path or just a name, in which case we will search /usr/share/fonts and /var/www/.fonts for a matching file ending in .ttf
al_typed_signature_font = "/usr/share/fonts/truetype/google-fonts/BadScript-Regular.ttf"
al_typed_signature_font = "/usr/share/fonts/truetype/google-fonts/BadScript-Regular.ttf"
---
code: |
# Allow users to import answer sets from JSON files.
# The global config 'enable answer set imports' is checked first; this variable allows
# interview authors to disable imports at the interview level even if global config permits them.
al_allow_answer_set_imports = True
1 change: 1 addition & 0 deletions docassemble/AssemblyLine/data/questions/al_visual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ data from code:
(
get_config('assembly line',{}).get('enable answer sets')
and get_config('assembly line',{}).get('enable answer set imports')
and al_allow_answer_set_imports
)
or (user_logged_in() and user_has_privilege('admin'))
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"users_name": "Alex",
"city": "Boston",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"users_name": "Alex",
"__class__": "builtins.object",
"city": "Boston"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"users_name": "Alex",
"_internal": {
"steps": 99
},
"city": "Boston"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"users": {
"_class": "docassemble.playground1.al_general.ALPeopleList",
"instanceName": "users",
"object_type": {
"_class": "type",
"name": "docassemble.playground1.al_general.ALIndividual"
},
"elements": [
{
"_class": "docassemble.playground1.al_general.ALIndividual",
"instanceName": "users[0]",
"name": {
"_class": "docassemble.playground1.al_general.IndividualName",
"instanceName": "users[0].name",
"first": "Client",
"last": "Example"
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"users": {
"_class": "docassemble.bad.Actor",
"instanceName": "users"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"users": {
"_class": "docassemble.AssemblyLine.al_general.ALPeopleList",
"instanceName": "users",
"object_type": {
"_class": "type",
"name": "docassemble.AssemblyLine.al_general.ALIndividual"
},
"elements": [
{
"_class": "docassemble.AssemblyLine.al_general.ALIndividual",
"instanceName": "users[0]",
"name": {
"_class": "docassemble.base.util.IndividualName",
"instanceName": "users[0].name",
"first": "Client",
"last": "Example"
},
"agent": {
"_class": "docassemble.AssemblyLine.al_general.ALIndividual",
"instanceName": "spouse"
},
"custom_text": "notes",
"custom_float": 1.25,
"custom_dict": {
"_class": "docassemble.base.util.DADict",
"instanceName": "users[0].custom_dict",
"elements": {
"case": "A123"
}
}
},
{
"_class": "docassemble.AssemblyLine.al_general.ALIndividual",
"instanceName": "spouse",
"name": {
"_class": "docassemble.base.util.IndividualName",
"instanceName": "spouse.name",
"first": "Spouse",
"last": "Example"
}
}
]
}
}
Loading
Loading