Skip to content

fix: do not replace :replacements inside of strings #14472

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
merged 3 commits into from
May 13, 2022
Merged

Conversation

ephys
Copy link
Member

@ephys ephys commented Apr 29, 2022

Pull Request Checklist

  • Have you added new tests to prevent regressions?
  • Does yarn test or yarn test-DIALECT pass with this change (including linting)?
  • Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?
  • Did you update the typescript typings accordingly (if applicable)?
  • Does the description below contain a link to an existing issue (Closes #[issue]) or a description of the issue you are solving?
  • Did you follow the commit message conventions explained in CONTRIBUTING.md?

Description Of Change

This PR backports the non-breaking changes from #14447 in v6

Only replacements have been backported, as unfortunately an escape hack was put in place to handle the bind parameter scenario, and reverting it would be a breaking change.

Closes #13817

Sorry, something went wrong.

Verified

This commit was signed with the committer’s verified signature.
ephys Alyx
@ephys ephys requested a review from WikiRik April 29, 2022 18:36
@ephys ephys requested a review from a team April 29, 2022 18:38
ephys added 2 commits April 29, 2022 21:11

Verified

This commit was signed with the committer’s verified signature.
ephys Alyx

Verified

This commit was signed with the committer’s verified signature.
ephys Alyx
Copy link
Member

@fzn0x fzn0x left a comment

Choose a reason for hiding this comment

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

We are good to go

Copy link
Member

@WikiRik WikiRik left a comment

Choose a reason for hiding this comment

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

Looks like nothing weird got backported

@ephys
Copy link
Member Author

ephys commented May 13, 2022

I just hope this doesn't introduce a breaking change, but in theory it will only be a breaking change if the feature was used 'incorrectly' (used a replacement in a place where a replacement should not be treated as one, like inside of a string).

@ephys
Copy link
Member Author

ephys commented May 13, 2022

I will skip the mysql 8 tests: It would seem we are experiencing the same issue as we did with mariadb #14174

@ephys ephys merged commit ccaa399 into v6 May 13, 2022
@ephys ephys deleted the ephys/fix-13817-v6 branch May 13, 2022 12:27
@WikiRik
Copy link
Member

WikiRik commented May 15, 2022

This is the cause of #14509
Not sure why Generate docs check passes while there is clearly a SyntaxError for it. @ephys would you be able to check it out?

@ephys ephys mentioned this pull request May 16, 2022
4 tasks
@github-actions
Copy link
Contributor

🎉 This PR is included in version 6.19.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@evangow
Copy link

evangow commented May 17, 2022

This just broke my deployment, so it's a breaking change for me at the very least.

"in theory it will only be a breaking change if the feature was used 'incorrectly' (used a replacement in a place where a replacement should not be treated as one, like inside of a string)"

Not sure what you mean by replacements being used incorrectly inside a string.

The docs do this:

await sequelize.query(
  'SELECT * FROM projects WHERE status = :status',
  {
    replacements: { status: 'active' },
    type: QueryTypes.SELECT
  }
);

https://sequelize.org/docs/v6/core-concepts/raw-queries/

@peterbaricic
Copy link

Please, if you break things in a minor release update then please provide an example how to use replacements with raw queries. Why is it incorrect usage?

@ephys
Copy link
Member Author

ephys commented May 18, 2022

By 'using a replacement inside a string' i meant a sql string, something like this;

await sequelize.query(
  `SELECT * FROM projects WHERE status = ':status'`,
  {
    replacements: { status: 'active' },
    type: QueryTypes.SELECT
  }
);

Note the quotes around :status. Injecting a replacement there would have resulted in sql injection. That's the thing we changed.

Your usage is correct and should not have broken. We'll look into what happened there.

@peterbaricic
Copy link

this is my usage, I use it to call sql functions, I don't have any strings there

const res: bulkItem[] = await database.query(`SELECT "portfolioInvestmentId", value FROM ${metric}(:fundId, array[:pciIds]::uuid[], :untilDate, :excludeEventId)`, {
      type: QueryTypes.SELECT,
      replacements: {
        fundId,
        pciIds,
        untilDate: dateToStr(untilDate),
        excludeEventId,
      },

@ephys
Copy link
Member Author

ephys commented May 18, 2022

This results in

SELECT "portfolioInvestmentId", value FROM table('abc', array[:pciIds]::uuid[], 'ijk', 'lmn')

Looks like the culprit is array[:pciIds]::uuid[]. Technically it's not a valid spot for a SQL value, but v6 doesn't have a way to specify the type of a replacement and this is breaking a legitimate workaround to that so we'll fix the parser in v6 to allow it.

@ephys
Copy link
Member Author

ephys commented May 18, 2022

PR: #14518

@evangow
Copy link

evangow commented May 18, 2022

I had 2 cases where using replacements broke. The first was with using it with LIMIT doing something like the below

await sequelize.query(
  'SELECT * FROM projects WHERE status = :status LIMIT :first',
  {
    replacements: { status: 'active', first: 20 },
    type: QueryTypes.SELECT
  }
);

The 2nd case was with using it with an array as well, such as WHERE "tags" @> ARRAY[:tags]::TEXT[]. Seems like the PR you submitted should fix this 2nd case but not the first

@ephys
Copy link
Member Author

ephys commented May 18, 2022

I just ran it against our latest v6 and it resulted in SELECT * FROM projects WHERE status = 'active' LIMIT 20

There may be something more going on. If you'd rather avoid posting the real query in public, you can send it to me privately on our slack (if you're comfortable with that), and I'll look into it.

Edit: It's resolved :)

@nabilzhafri
Copy link

nabilzhafri commented May 18, 2022

Sadly this wasn't marked as breaking changes. It breaks my usage, where I previously used for INTERVAL, and it was working fine before:

CASE WHEN ("Appointments"."canceledAt" >= ("Appointments"."startDate" - INTERVAL ':cutOffPeriod hours')) THEN
     'cancelled < :cutOffPeriod hours'

In this case, the variable needs to be inside string

Any suggestion how to solve this

@ephys
Copy link
Member Author

ephys commented May 18, 2022

We apologize for that but unfortunately that is considered to be a bug that was severe enough to warrant patching in all versions as it lead to SQL injections.

In your case, unless you ensured that cutOffPeriod was a number, you were vulnerable to SQL injections. If you do ensure the input is only a number, then you can concatenate your variable in your string, it's unsafe, but it's not more unsafe than what you were doing previously:

await sequelize.query(
  `CASE WHEN ("Appointments"."canceledAt" >= ("Appointments"."startDate" - INTERVAL '${cutOffPeriod} hours')) THEN 'cancelled < ${cutOffPeriod} hours'`,
  {
    type: QueryTypes.SELECT
  }
);

Or you could do this, which is safe:

await sequelize.query(
  `CASE WHEN ("Appointments"."canceledAt" >= ("Appointments"."startDate" - INTERVAL :cutOff1)) THEN :cutOff2`,
  {
    replacements: {
      cutOff1: `${cutOffPeriod} hours`,
      cutOff2: `cancelled < ${cutOffPeriod} hours`,
    },
    type: QueryTypes.SELECT
  }
);

@nabilzhafri
Copy link

@ephys i see, if thats the concern that make sense to patch such thing. In my case, it's all constructed internally, so it wasn't really a problem.

And thanks for the solution! That'll work.
In my case I have to go with second solution because my queries are all in .sql files

@Caerbannog
Copy link

Here are two more cases where the replacement is not detected, with PostgreSQL.

  1. JSONB indexing
select ('{"abc": 1}'::jsonb)->>:key;
  1. Inside a do block. I assume that $$ triggers the "inside of strings" limitation.
do $$
  begin
    if :value then
     refresh materialized view abc;
   end if;
  end
$$;
select 1;

We worked around these cases by concatenating strings, and taking care of sanitization ourselves.

@ephys
Copy link
Member Author

ephys commented May 26, 2022

Thank you! we'll look into fixing these two :)

According to postgres' documentation, DO expects a string, and bind parameters are supported in it. Going to need to add a good number of tests to ensure we properly parse strings within strings

aliatsis pushed a commit to creditiq/sequelize that referenced this pull request Jun 2, 2022
@WikiRik
Copy link
Member

WikiRik commented Jun 13, 2022

@ephys I'm clearing up my notifications and trying to ping you in some of those so they're back on your radar

@@ -1,5 +1,5 @@
export type Escapable = undefined | null | boolean | number | string | Date;
export function escapeId(val: string, forbidQualified?: boolean): string;
export function escape(val: Escapable | Escapable[], timeZone?: string, dialect?: string, format?: string): string;
export function escape(val: Escapable | Escapable[], timeZone?: string, dialect?: string, format?: boolean): string;
export function format(sql: string, values: unknown[], timeZone?: string, dialect?: string): string;
export function formatNamedParameters(sql: string, values: unknown[], timeZone?: string, dialect?: string): string;

Choose a reason for hiding this comment

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

It looks like this wasn't updated, so existing TS users won't see the new injectReplacements function, nor will they see the deprecation.

@ephys
Copy link
Member Author

ephys commented Jun 29, 2022

@Caerbannog I haven't been able to resolve the DO and EXECUTE use cases.

The problem is that I cannot guarantee that the code injected inside of the string is safe. It requires a much more complex escape function.

In your example, if I set value to '$$', the result will be

do $$
  begin
    if '$$' then
      refresh materialized view abc;
    end if;
  end
$$;

Which is a syntax error because postgres considers $$ to be the end of the string passed to the do block.

I don't see a way to escape $$, so this may simply be impossible

This limitation wasn't introduced by this PR, so that code was vulnerable before this PR, but I can't enable it until we've figured out a way to safely escape the value that needs to be injected inside of the string.

@vivekkairi
Copy link

Hi @ephys

Screenshot 2022-09-26 at 6 34 18 PM

With earlier sequelize versions, passing undefined as positional parameter was consider as null but now an error is been thrown resulting in a breaking change.


const remainingString = sqlString.slice(i, sqlString.length);

const match = remainingString.match(/^:(?<name>[a-z_][0-9a-z_]*)(?:\)|,|$|\s|::)/i);
Copy link

@hbrysiewicz hbrysiewicz Jul 12, 2023

Choose a reason for hiding this comment

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

This regex does not take into account an ending ; on the statement, so I corrected to remainingString.match(/^:(?<name>[a-z_][0-9a-z_]*)(?:\)|,|\;|$|\s|::)/i);.

Then, I discovered it does not like when the sql statement has JSON in it such as: UPDATE table SET value = value || '{"column::form": :formTemplateId}' where key = 'formTemplates.defaultForms' (with replacements for :formTemplateId passed in). It was not capturing the named parameter inside the jsonb update, :formTemplateId. I fixed this by moving this block (lines 134-166) up to above line 53 (if (isString)). I also had to update the regex here again, to include the option for the ending json block: remainingString.match(/^:(?<name>[a-z_][0-9a-z_]*)(?:\)|,|\;|\}|$|\s|::)/i);.

Would there be any reason why we would not want to do the named parameter check before the other char checks?

Copy link
Member Author

@ephys ephys Jul 13, 2023

Choose a reason for hiding this comment

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

The second part is not a bug, your :formTemplateId is inside of a SQL string so we won't replace it because that will lead to sql injection as well as the risk of mangling data. You need to build your entire string before replacing it, or concat the replaced value to the rest of your string using the SQL concat function

The ; thing may be a bug, I'd need an example of a failing query to make sure

@denodaeus
Copy link

Hi @ephys

Screenshot 2022-09-26 at 6 34 18 PM

With earlier sequelize versions, passing undefined as positional parameter was consider as null but now an error is been thrown resulting in a breaking change.

This is also how it broke us. It is often not uncommon in javascript to pass undefined in as positional arguments. It's worse that this slipped in as not a major breaking change - this is a pretty major, breaking API change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet